From 136c0a0ecc71911f8c57bee1884fa3406d9974aa Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Thu, 26 Feb 2026 22:28:09 -0600 Subject: [PATCH] billing refactor, move billing to appview, move webhooks to appview --- .air.toml | 2 +- cmd/hold/repo.go | 2 +- cmd/relay-compare/main.go | 113 ++- config-appview.example.yaml | 78 ++ config-hold.example.yaml | 30 +- deploy/upcloud/configs/appview.yaml.tmpl | 1 + deploy/upcloud/configs/hold.yaml.tmpl | 10 +- docker-compose.yml | 8 +- docs/BILLING_REFACTOR.md | 348 ++++++++ docs/HOLD_XRPC_ENDPOINTS.md | 16 +- lexicons/io/atcr/hold/addWebhook.json | 59 -- lexicons/io/atcr/hold/captain.json | 8 - lexicons/io/atcr/hold/deleteWebhook.json | 41 - lexicons/io/atcr/hold/listTiers.json | 50 ++ lexicons/io/atcr/hold/listWebhooks.json | 86 -- lexicons/io/atcr/hold/testWebhook.json | 41 - lexicons/io/atcr/hold/updateCrewTier.json | 53 ++ lexicons/io/atcr/hold/webhook.json | 32 - lexicons/io/atcr/sailor/webhook.json | 42 - lexicons/io/atcr/tag.json | 6 +- pkg/appview/config.go | 25 +- pkg/appview/db/annotations.go | 36 +- pkg/appview/db/hold_store.go | 112 +-- .../0015_create_webhooks_and_scans.yaml | 27 + .../0016_drop_supporter_badge_tiers.yaml | 3 + pkg/appview/db/queries.go | 273 +++++- pkg/appview/db/schema.sql | 28 +- pkg/appview/handlers/base.go | 12 +- pkg/appview/handlers/settings.go | 193 ++-- pkg/appview/handlers/storage.go | 53 +- pkg/appview/handlers/subscription.go | 328 +------ pkg/appview/handlers/user.go | 14 +- pkg/appview/handlers/webhooks.go | 316 +++---- pkg/appview/holdclient/tier_query.go | 63 ++ pkg/appview/holdclient/tier_update.go | 97 ++ pkg/appview/jetstream/backfill.go | 11 +- pkg/appview/jetstream/processor.go | 198 ++++- pkg/appview/jetstream/worker.go | 6 + pkg/appview/public/icons.svg | 2 +- pkg/appview/routes/routes.go | 73 +- pkg/appview/server.go | 202 ++++- pkg/appview/src/js/app.js | 4 +- pkg/appview/templates/pages/settings.html | 262 +----- pkg/appview/templates/partials/hold_card.html | 32 + .../templates/partials/hold_selector.html | 36 + .../templates/partials/other_holds_table.html | 47 + .../templates/partials/storage_stats.html | 6 - .../templates/partials/subscription_info.html | 82 +- .../templates/partials/webhooks_list.html | 8 +- pkg/appview/ui.go | 2 + pkg/appview/webhooks/dispatch.go | 234 +++++ pkg/appview/webhooks/format.go | 184 ++++ pkg/appview/webhooks/types.go | 44 + pkg/atproto/cbor_gen.go | 300 +------ pkg/atproto/endpoints.go | 42 +- pkg/atproto/generate.go | 1 - pkg/atproto/lexicon.go | 73 +- pkg/auth/appview_token.go | 77 ++ pkg/billing/billing.go | 730 +++++++++++++++ pkg/billing/billing_stub.go | 72 ++ pkg/billing/config.go | 83 ++ pkg/billing/handlers.go | 57 ++ pkg/billing/types.go | 59 ++ pkg/hold/admin/public/icons.svg | 2 +- pkg/hold/billing/billing.go | 565 ------------ pkg/hold/billing/billing_stub.go | 60 -- pkg/hold/billing/config.go | 132 --- pkg/hold/billing/config_test.go | 357 -------- pkg/hold/billing/handlers.go | 222 ----- pkg/hold/billing/types.go | 65 -- pkg/hold/config.go | 36 +- pkg/hold/pds/auth.go | 152 ++++ pkg/hold/pds/scan_broadcaster.go | 18 - pkg/hold/pds/server.go | 3 +- pkg/hold/pds/webhooks.go | 831 ------------------ pkg/hold/pds/xrpc.go | 118 ++- pkg/hold/quota/config.go | 75 +- pkg/hold/quota/config_test.go | 27 - pkg/hold/server.go | 69 +- scanner/cmd/scanner/main.go | 7 + scanner/internal/client/hold.go | 9 +- scanner/internal/config/config.go | 2 +- scanner/internal/scan/grype.go | 19 +- scanner/internal/scan/syft.go | 5 +- scanner/internal/scan/worker.go | 26 +- 85 files changed, 4049 insertions(+), 4284 deletions(-) create mode 100644 docs/BILLING_REFACTOR.md delete mode 100644 lexicons/io/atcr/hold/addWebhook.json delete mode 100644 lexicons/io/atcr/hold/deleteWebhook.json create mode 100644 lexicons/io/atcr/hold/listTiers.json delete mode 100644 lexicons/io/atcr/hold/listWebhooks.json delete mode 100644 lexicons/io/atcr/hold/testWebhook.json create mode 100644 lexicons/io/atcr/hold/updateCrewTier.json delete mode 100644 lexicons/io/atcr/hold/webhook.json delete mode 100644 lexicons/io/atcr/sailor/webhook.json create mode 100644 pkg/appview/db/migrations/0015_create_webhooks_and_scans.yaml create mode 100644 pkg/appview/db/migrations/0016_drop_supporter_badge_tiers.yaml create mode 100644 pkg/appview/holdclient/tier_query.go create mode 100644 pkg/appview/holdclient/tier_update.go create mode 100644 pkg/appview/templates/partials/hold_card.html create mode 100644 pkg/appview/templates/partials/hold_selector.html create mode 100644 pkg/appview/templates/partials/other_holds_table.html create mode 100644 pkg/appview/webhooks/dispatch.go create mode 100644 pkg/appview/webhooks/format.go create mode 100644 pkg/appview/webhooks/types.go create mode 100644 pkg/auth/appview_token.go create mode 100644 pkg/billing/billing.go create mode 100644 pkg/billing/billing_stub.go create mode 100644 pkg/billing/config.go create mode 100644 pkg/billing/handlers.go create mode 100644 pkg/billing/types.go delete mode 100644 pkg/hold/billing/billing.go delete mode 100644 pkg/hold/billing/billing_stub.go delete mode 100644 pkg/hold/billing/config.go delete mode 100644 pkg/hold/billing/config_test.go delete mode 100644 pkg/hold/billing/handlers.go delete mode 100644 pkg/hold/billing/types.go delete mode 100644 pkg/hold/pds/webhooks.go diff --git a/.air.toml b/.air.toml index c871b95..c0b0668 100644 --- a/.air.toml +++ b/.air.toml @@ -7,7 +7,7 @@ poll = true poll_interval = 500 # Pre-build: generate assets if missing (each string is a shell command) pre_cmd = ["go generate ./pkg/appview/..."] -cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview" +cmd = "go build -tags billing -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview" entrypoint = ["./tmp/atcr-appview", "serve", "--config", "config-appview.example.yaml"] include_ext = ["go", "html", "css", "js"] exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules", "pkg/hold"] diff --git a/cmd/hold/repo.go b/cmd/hold/repo.go index 274e95f..45b7acd 100644 --- a/cmd/hold/repo.go +++ b/cmd/hold/repo.go @@ -131,7 +131,7 @@ func openHoldPDS(ctx context.Context, cfg *hold.Config) (*pds.HoldPDS, func(), e return nil, nil, fmt.Errorf("failed to open hold database: %w", err) } - holdPDS, err := pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, false, holdDB.DB) + holdPDS, err := pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, false, holdDB.DB) if err != nil { holdDB.Close() return nil, nil, fmt.Errorf("failed to initialize PDS: %w", err) diff --git a/cmd/relay-compare/main.go b/cmd/relay-compare/main.go index 5e59dfe..47cb795 100644 --- a/cmd/relay-compare/main.go +++ b/cmd/relay-compare/main.go @@ -41,29 +41,29 @@ var allCollections = []string{ atproto.TagCollection, // io.atcr.tag atproto.SailorProfileCollection, // io.atcr.sailor.profile atproto.StarCollection, // io.atcr.sailor.star - atproto.SailorWebhookCollection, // io.atcr.sailor.webhook atproto.RepoPageCollection, // io.atcr.repo.page atproto.CaptainCollection, // io.atcr.hold.captain atproto.CrewCollection, // io.atcr.hold.crew atproto.LayerCollection, // io.atcr.hold.layer atproto.StatsCollection, // io.atcr.hold.stats atproto.ScanCollection, // io.atcr.hold.scan - atproto.WebhookCollection, // io.atcr.hold.webhook } type summaryRow struct { - collection string - counts []int - status string // "sync", "diff", "error" - diffCount int - realGaps int // verified: record exists on PDS but relay is missing it - ghosts int // verified: record doesn't exist on PDS, relay has stale entry + collection string + counts []int + status string // "sync", "diff", "error" + diffCount int + realGaps int // verified: record exists on PDS but relay is missing it + ghosts int // verified: record doesn't exist on PDS, relay has stale entry + deactivated int // verified: account deactivated/deleted on PDS } // verifyResult holds the PDS verification result for a (DID, collection) pair. type verifyResult struct { - exists bool - err error + exists bool + deactivated bool // account deactivated/deleted on PDS + err error } // key identifies a (collection, relay-or-DID) pair for result lookups. @@ -211,6 +211,7 @@ func main() { totalMissing := 0 totalRealGaps := 0 totalGhosts := 0 + totalDeactivated := 0 for _, col := range cols { fmt.Printf("\n%s%s━━━ %s ━━━%s\n", cBold, cCyan, col, cReset) @@ -258,6 +259,10 @@ func main() { suffix = fmt.Sprintf(" %s(verify: unknown)%s", cDim, cReset) } else if vr.err != nil { suffix = fmt.Sprintf(" %s(verify: %s)%s", cDim, vr.err, cReset) + } else if vr.deactivated { + suffix = fmt.Sprintf(" %s← deactivated%s", cDim, cReset) + row.deactivated++ + totalDeactivated++ } else if vr.exists { suffix = fmt.Sprintf(" %s← real gap%s", cRed, cReset) row.realGaps++ @@ -272,8 +277,18 @@ func main() { } } + // When verifying, ghost/deactivated-only diffs are considered in sync + if !inSync && *verify && row.realGaps == 0 { + inSync = true + } + if inSync { - fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset) + notes := formatSyncNotes(row.ghosts, row.deactivated) + if notes != "" { + fmt.Printf(" %s✓ in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset) + } else { + fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset) + } row.status = "sync" } else { row.status = "diff" @@ -282,10 +297,10 @@ func main() { } // Summary table - printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts) + printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts, totalDeactivated) } -func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts int) { +func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts, totalDeactivated int) { fmt.Printf("\n%s%s━━━ Summary ━━━%s\n\n", cBold, cCyan, cReset) colW := 28 @@ -321,11 +336,20 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in } switch row.status { case "sync": - fmt.Printf(" %s✓ in sync%s", cGreen, cReset) + notes := formatSyncNotes(row.ghosts, row.deactivated) + if notes != "" { + fmt.Printf(" %s✓ in sync%s %s(%s)%s", cGreen, cReset, cDim, notes, cReset) + } else { + fmt.Printf(" %s✓ in sync%s", cGreen, cReset) + } case "diff": if showVerify { - fmt.Printf(" %s≠ %d missing%s %s(%d real, %d ghost)%s", - cYellow, row.diffCount, cReset, cDim, row.realGaps, row.ghosts, cReset) + notes := formatSyncNotes(row.ghosts, row.deactivated) + if notes != "" { + notes = ", " + notes + } + fmt.Printf(" %s≠ %d missing%s %s(%s)%s", + cYellow, row.realGaps, cReset, cDim, fmt.Sprintf("%d real%s", row.realGaps, notes), cReset) } else { fmt.Printf(" %s≠ %d missing%s", cYellow, row.diffCount, cReset) } @@ -338,16 +362,39 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in // Footer fmt.Println() if totalMissing > 0 { - fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset) - if showVerify { - fmt.Printf(" %s%d real gaps%s (record exists on PDS), %s%d ghosts%s (record deleted from PDS)\n", - cRed, totalRealGaps, cReset, cDim, totalGhosts, cReset) + if showVerify && totalRealGaps == 0 { + notes := formatSyncNotes(totalGhosts, totalDeactivated) + fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset) + } else { + if showVerify { + fmt.Printf("%s%d real gaps across relays%s", cYellow, totalRealGaps, cReset) + notes := formatSyncNotes(totalGhosts, totalDeactivated) + if notes != "" { + fmt.Printf(" %s(%s)%s", cDim, notes, cReset) + } + fmt.Println() + } else { + fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset) + } } } else { fmt.Printf("%s✓ All relays fully in sync%s\n", cGreen, cReset) } } +// formatSyncNotes builds a parenthetical like "2 ghost, 1 deactivated" for sync status. +// Returns empty string if both counts are zero. +func formatSyncNotes(ghosts, deactivated int) string { + var parts []string + if ghosts > 0 { + parts = append(parts, fmt.Sprintf("%d ghost", ghosts)) + } + if deactivated > 0 { + parts = append(parts, fmt.Sprintf("%d deactivated", deactivated)) + } + return strings.Join(parts, ", ") +} + // verifyDiffs resolves each diff DID to its PDS and checks if records actually exist. func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { // Collect unique (DID, collection) pairs to verify @@ -402,11 +449,19 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { k := key{dc.col, dc.did} - // Check if DID resolution failed + // Check if DID resolution failed — could mean account is deactivated/tombstoned if err, ok := pdsErrors[dc.did]; ok { - mu.Lock() - results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)} - mu.Unlock() + errStr := err.Error() + if strings.Contains(errStr, "no PDS endpoint") || + strings.Contains(errStr, "not found") { + mu.Lock() + results[k] = verifyResult{deactivated: true} + mu.Unlock() + } else { + mu.Lock() + results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)} + mu.Unlock() + } return } @@ -415,7 +470,15 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { records, _, err := client.ListRecordsForRepo(ctx, dc.did, dc.col, 1, "") mu.Lock() if err != nil { - results[k] = verifyResult{err: err} + errStr := err.Error() + if strings.Contains(errStr, "Could not find repo") || + strings.Contains(errStr, "RepoDeactivated") || + strings.Contains(errStr, "RepoTakendown") || + strings.Contains(errStr, "RepoSuspended") { + results[k] = verifyResult{deactivated: true} + } else { + results[k] = verifyResult{err: err} + } } else { results[k] = verifyResult{exists: len(records) > 0} } diff --git a/config-appview.example.yaml b/config-appview.example.yaml index 3ee9577..229fdde 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -37,6 +37,9 @@ server: client_short_name: ATCR # Separate domains for OCI registry API (e.g. ["buoy.cr"]). First is primary. Browser visits redirect to BaseURL. registry_domains: [] + # DIDs of holds this appview manages billing for. Tier updates are pushed to these holds. + managed_holds: + - did:web:172.28.0.3%3A8080 # Web UI settings. ui: # SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals. @@ -65,6 +68,8 @@ jetstream: - wss://jetstream1.us-east.bsky.network/subscribe # Sync existing records from PDS on startup. backfill_enabled: true + # How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup. + backfill_interval: 24h0m0s # Relay endpoints for backfill, tried in order on failure. relay_endpoints: - https://relay1.us-east.bsky.network @@ -85,3 +90,76 @@ legal: company_name: "" # Governing law jurisdiction for legal terms. jurisdiction: "" +# Stripe billing integration (requires -tags billing build). +billing: + # Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set. + stripe_secret_key: "" + # Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence). + webhook_secret: "" + # ISO 4217 currency code (e.g. "usd"). + currency: usd + # Redirect URL after successful checkout. Use {base_url} placeholder. + success_url: '{base_url}/settings#storage' + # Redirect URL after cancelled checkout. Use {base_url} placeholder. + cancel_url: '{base_url}/settings#storage' + # Subscription tiers ordered by rank (lowest to highest). + tiers: + - # Tier name. Position in list determines rank (0-based). + name: free + # Short description shown on the plan card. + description: Get started with basic storage + # List of features included in this tier. + features: [] + # Stripe price ID for monthly billing. Empty = free tier. + stripe_price_monthly: "" + # Stripe price ID for yearly billing. + stripe_price_yearly: "" + # Maximum webhooks for this tier (-1 = unlimited). + max_webhooks: 1 + # Allow all webhook trigger types (not just first-scan). + webhook_all_triggers: false + supporter_badge: false + - # Tier name. Position in list determines rank (0-based). + name: deckhand + # Short description shown on the plan card. + description: Get started with basic storage + # List of features included in this tier. + features: [] + # Stripe price ID for monthly billing. Empty = free tier. + stripe_price_monthly: "" + # Stripe price ID for yearly billing. + stripe_price_yearly: "" + # Maximum webhooks for this tier (-1 = unlimited). + max_webhooks: 1 + # Allow all webhook trigger types (not just first-scan). + webhook_all_triggers: false + supporter_badge: true + - # Tier name. Position in list determines rank (0-based). + name: bosun + # Short description shown on the plan card. + description: More storage with scan-on-push + # List of features included in this tier. + features: [] + # Stripe price ID for monthly billing. Empty = free tier. + stripe_price_monthly: "" + # Stripe price ID for yearly billing. + stripe_price_yearly: "" + # Maximum webhooks for this tier (-1 = unlimited). + max_webhooks: 10 + # Allow all webhook trigger types (not just first-scan). + webhook_all_triggers: true + supporter_badge: true + # - # Tier name. Position in list determines rank (0-based). + # name: quartermaster + # # Short description shown on the plan card. + # description: Maximum storage for power users + # # List of features included in this tier. + # features: [] + # # Stripe price ID for monthly billing. Empty = free tier. + # stripe_price_monthly: price_xxx + # # Stripe price ID for yearly billing. + # stripe_price_yearly: price_yyy + # # Maximum webhooks for this tier (-1 = unlimited). + # max_webhooks: -1 + # # Allow all webhook trigger types (not just first-scan). + # webhook_all_triggers: true diff --git a/config-hold.example.yaml b/config-hold.example.yaml index cf0f981..f22beda 100644 --- a/config-hold.example.yaml +++ b/config-hold.example.yaml @@ -47,8 +47,8 @@ server: test_mode: false # Request crawl from this relay on startup to make the embedded PDS discoverable. relay_endpoint: "" - # Preferred appview URL for links in webhooks and Bluesky posts, e.g. "https://seamark.dev". - appview_url: https://atcr.io + # DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key. + appview_did: did:web:172.28.0.2%3A5000 # Read timeout for HTTP requests. read_timeout: 5m0s # Write timeout for HTTP requests. @@ -101,48 +101,34 @@ gc: quota: # Quota tiers ordered by rank (lowest to highest). Position determines rank. tiers: + - # Tier name used as the key for crew assignments. + name: free + # Storage quota limit (e.g. "5GB", "50GB", "1TB"). + quota: 5GB + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. + scan_on_push: false - # Tier name used as the key for crew assignments. name: deckhand # Storage quota limit (e.g. "5GB", "50GB", "1TB"). quota: 5GB # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. scan_on_push: false - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. - max_webhooks: 1 - # Allow all webhook trigger types. Free tiers only get scan:first. - webhook_all_triggers: false - # Show supporter badge on user profiles for members at this tier. - supporter_badge: false - # Tier name used as the key for crew assignments. name: bosun # Storage quota limit (e.g. "5GB", "50GB", "1TB"). quota: 50GB # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. scan_on_push: true - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. - max_webhooks: 5 - # Allow all webhook trigger types. Free tiers only get scan:first. - webhook_all_triggers: true - # Show supporter badge on user profiles for members at this tier. - supporter_badge: true - # Tier name used as the key for crew assignments. name: quartermaster # Storage quota limit (e.g. "5GB", "50GB", "1TB"). quota: 100GB # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. scan_on_push: true - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. - max_webhooks: -1 - # Allow all webhook trigger types. Free tiers only get scan:first. - webhook_all_triggers: true - # Show supporter badge on user profiles for members at this tier. - supporter_badge: true # Default tier assignment for new crew members. defaults: # Tier assigned to new crew members who don't have an explicit tier. new_crew_tier: deckhand - # Show supporter badge on the hold owner's profile. - owner_badge: true # Vulnerability scanner settings. Empty disables scanning. scanner: # Shared secret for scanner WebSocket auth. Empty disables scanning. diff --git a/deploy/upcloud/configs/appview.yaml.tmpl b/deploy/upcloud/configs/appview.yaml.tmpl index 3c9a5a5..e70bb1d 100644 --- a/deploy/upcloud/configs/appview.yaml.tmpl +++ b/deploy/upcloud/configs/appview.yaml.tmpl @@ -34,6 +34,7 @@ jetstream: - wss://jetstream2.us-east.bsky.network/subscribe - wss://jetstream1.us-east.bsky.network/subscribe backfill_enabled: true + backfill_interval: 24h relay_endpoints: - https://relay1.us-east.bsky.network - https://relay1.us-west.bsky.network diff --git a/deploy/upcloud/configs/hold.yaml.tmpl b/deploy/upcloud/configs/hold.yaml.tmpl index 3633dee..bffc828 100644 --- a/deploy/upcloud/configs/hold.yaml.tmpl +++ b/deploy/upcloud/configs/hold.yaml.tmpl @@ -21,7 +21,7 @@ server: successor: "" test_mode: false relay_endpoint: "" - appview_url: https://seamark.dev + appview_did: did:web:seamark.dev read_timeout: 5m0s write_timeout: 5m0s registration: @@ -50,22 +50,14 @@ quota: tiers: - name: deckhand quota: 5GB - max_webhooks: 1 - name: bosun quota: 50GB scan_on_push: true - max_webhooks: 5 - webhook_all_triggers: true - supporter_badge: true - name: quartermaster quota: 100GB scan_on_push: true - max_webhooks: -1 - webhook_all_triggers: true - supporter_badge: true defaults: new_crew_tier: deckhand - owner_badge: true scanner: secret: "{{.ScannerSecret}}" rescan_interval: 168h0m0s diff --git a/docker-compose.yml b/docker-compose.yml index 93d9607..998e086 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,10 @@ services: ATCR_LOG_LEVEL: debug LOG_SHIPPER_BACKEND: victoria LOG_SHIPPER_URL: http://172.28.0.10:9428 + # Stripe billing (only used with -tags billing) + STRIPE_SECRET_KEY: sk_test_ + STRIPE_PUBLISHABLE_KEY: pk_test_ + STRIPE_WEBHOOK_SECRET: whsec_ # Limit local Docker logs - real logs go to Victoria Logs # Local logs just for live tailing (docker logs -f) logging: @@ -57,10 +61,6 @@ services: HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg HOLD_REGISTRATION_ALLOW_ALL_CREW: true HOLD_SERVER_TEST_MODE: true - # Stripe billing (only used with -tags billing) - STRIPE_SECRET_KEY: sk_test_ - STRIPE_PUBLISHABLE_KEY: pk_test_ - STRIPE_WEBHOOK_SECRET: whsec_ HOLD_LOG_LEVEL: debug LOG_SHIPPER_BACKEND: victoria LOG_SHIPPER_URL: http://172.28.0.10:9428 diff --git a/docs/BILLING_REFACTOR.md b/docs/BILLING_REFACTOR.md new file mode 100644 index 0000000..6a26754 --- /dev/null +++ b/docs/BILLING_REFACTOR.md @@ -0,0 +1,348 @@ +# Billing & Webhooks Refactor: Move to AppView + +## Motivation + +The current billing model is **per-hold**: each hold operator runs their own Stripe integration, manages their own tiers, and users pay each hold separately. This creates problems: + +1. **Multi-hold confusion**: A user on 3 holds could have 3 separate Stripe subscriptions with no unified view +2. **Orphaned subscriptions**: Users can end up paying for holds they no longer use after switching their active hold +3. **Complex UI**: The settings page needs to surface billing per-hold, with separate "Manage Billing" links for each +4. **Captain-only billing**: Only hold captains can set up Stripe. Self-hosted hold operators who want to charge users would need their own Stripe account per hold + +The proposed model is **per-appview**: a single Stripe integration on the appview, one subscription per user, covering all holds that appview manages. + +## Current Architecture + +``` +User ──Settings UI──→ AppView ──XRPC──→ Hold ──Stripe API──→ Stripe + ↑ + Stripe Webhooks +``` + +### What lives where today + +| Component | Location | Notes | +|-----------|----------|-------| +| Stripe customer management | Hold (`pkg/hold/billing/`) | Build tag: `-tags billing` | +| Stripe checkout/portal | Hold XRPC endpoints | Authenticated via service token | +| Stripe webhook receiver | Hold (`stripeWebhook` endpoint) | Updates crew tier on subscription change | +| Tier definitions + pricing | Hold config (`quotas.yaml`, `billing` section) | Captain configures | +| Quota enforcement | Hold (`pkg/hold/quota/`) | Checks tier limit on push | +| Storage quota calculation | Hold PDS layer records | Deduped per-user | +| Subscription UI | AppView handlers | Proxies all calls to hold | +| Webhook management (scan) | Hold PDS + SQLite | URL/secret in SQLite, metadata in PDS record | +| Webhook dispatch | Hold (`scan_broadcaster.go`) | Sends on scan completion | +| Sailor webhook record | User's PDS | Links to hold's private webhook record | + +## Proposed Architecture + +``` +User ──Settings UI──→ AppView ──Stripe API──→ Stripe + │ ↑ + │ Stripe Webhooks + │ + ├──XRPC──→ Hold A (quota enforcement, scan results) + ├──XRPC──→ Hold B + └──XRPC──→ Hold C + + AppView signs attestation + │ + └──→ Hold stores in PDS (trust anchor) +``` + +### What moves to AppView + +| Component | From | To | Notes | +|-----------|------|----|-------| +| Stripe customer management | Hold | AppView | One customer per user, not per hold | +| Stripe checkout/portal | Hold | AppView | Single subscription covers all holds | +| Stripe webhook receiver | Hold | AppView | AppView updates tier across all holds | +| Tier definitions + pricing | Hold config | AppView config | AppView defines billing tiers | +| Scan webhooks (storage + dispatch) | Hold | AppView | AppView has user context, scan data comes via Jetstream/XRPC | + +### What stays on the hold + +| Component | Notes | +|-----------|-------| +| Quota enforcement | Hold still checks tier limit on push | +| Storage quota calculation | Layer records stay in hold PDS | +| Tier definitions (quota only) | Hold defines storage limits per tier, no pricing | +| Scan execution + results | Scanner still talks to hold, results stored in hold PDS | +| Crew tier field | Source of truth for enforcement, updated by appview | + +## Billing Model + +### One subscription, all holds + +A user pays the appview once. Their subscription tier applies across every hold the appview manages. + +``` +AppView billing tiers: [Free] [Tier 1] [Tier 2] + │ │ │ + ▼ ▼ ▼ +Hold A tiers (3GB/10GB/50GB): deckhand bosun quartermaster +Hold B tiers (5GB/20GB/∞): deckhand bosun quartermaster +``` + +### Tier pairing + +The appview defines N billing slots. Each hold defines its own tier list with storage quotas. The appview maps its billing slots to each hold's lowest N tiers by rank order. + +- AppView doesn't need to know tier names — just "slot 1, slot 2, slot 3" +- Each hold independently decides what storage limit each tier gets +- The settings UI shows the range: "5-10 GB depending on region" or "minimum 5 GB" + +### Hold captains who want to charge + +If a hold captain wants to charge their own users (not through the shared appview), they spin up their own appview instance with their own Stripe account. The billing code stays the same — it just runs on their appview instead of the shared one. + +## AppView-Hold Trust Model + +### Problem + +The appview needs to tell holds "user X is tier Y." The hold needs to trust that instruction. If domains change, the hold needs to verify the appview's identity. + +### Attestation handshake + +1. **Hold config** already has `server.appview_url` (preferred appview) +2. **AppView config** gains a `managed_holds` list (DIDs of holds it manages) +3. On first connection, the appview signs an attestation with its private key: + ```json + { + "$type": "io.atcr.appview.attestation", + "appviewDid": "did:web:atcr.io", + "holdDid": "did:web:hold01.atcr.io", + "issuedAt": "2026-02-23T...", + "signature": "" + } + ``` +4. The hold stores this attestation in its embedded PDS +5. On subsequent requests, the hold can challenge the appview: present the attestation, appview proves it holds the matching private key +6. If the appview's domain changes, the attestation (tied to DID, not URL) remains valid + +### Trust verification flow + +``` +AppView boots → checks managed_holds list + → for each hold: + → calls hold's describeServer endpoint to verify DID + → signs attestation { appviewDid, holdDid, issuedAt } + → sends to hold via XRPC + → hold stores in PDS as io.atcr.hold.appview record + +Hold receives tier update from appview: + → checks: does this request come from my preferred appview? + → verifies: signature on stored attestation matches appview's current key + → if valid: updates crew tier + → if invalid: rejects, logs warning +``` + +### Key material + +- **AppView**: P-256 key (already exists at `/var/lib/atcr/oauth/client.key`, used for OAuth) +- **Hold**: K-256 key (PDS signing key) +- Attestation is signed by appview's P-256 key, verifiable by anyone with the appview's public key (available via DID document) + +## Webhooks: Move to AppView + +### Why move + +Scan webhooks currently live on the hold, but: +- The webhook payload needs user handles, repository names, tags — all resolved by the appview +- The hold only has DIDs and digests +- The appview already processes scan records via Jetstream (backfill + live) +- Webhook secrets shouldn't need to live on every hold the user pushes to + +### New flow + +``` +Scanner completes scan + → Hold stores scan record in PDS + → Jetstream delivers scan record to AppView + → AppView resolves user handle, repo name, tags + → AppView dispatches webhooks with full context +``` + +### What changes + +| Aspect | Current (hold) | Proposed (appview) | +|--------|---------------|-------------------| +| Webhook storage | Hold SQLite + PDS record | AppView DB + user's PDS record | +| Webhook secrets | Hold SQLite (`webhook_secrets` table) | AppView DB | +| Dispatch trigger | `scan_broadcaster.go` on scan completion | Jetstream processor on `io.atcr.hold.scan` record | +| Payload enrichment | Hold fetches handle from appview metadata | AppView has full context natively | +| Discord/Slack formatting | Hold (`webhooks.go`) | AppView (same code, moved) | +| Tier-based limits | Hold quota manager | AppView billing tier | +| XRPC endpoints | Hold (`listWebhooks`, `addWebhook`, etc.) | AppView API endpoints (already exist as proxies) | + +### Webhook record changes + +The `io.atcr.sailor.webhook` record in the user's PDS stays. It already stores `holdDid` and `triggers`. The `privateCid` field (linking to hold's internal record) becomes unnecessary since appview owns the full webhook now. + +The `io.atcr.hold.webhook` record in the hold's PDS is no longer needed. Webhooks are appview-scoped, not hold-scoped. + +### Migration path + +1. AppView gains webhook storage in its own DB (new table) +2. AppView gains webhook dispatch in its Jetstream processor +3. Hold's webhook endpoints deprecated (return 410 Gone after transition period) +4. Existing hold webhook records migrated via one-time script reading from hold XRPC + user PDS + +## Config Changes + +### AppView config additions + +```yaml +server: + # Existing + default_hold_did: "did:web:hold01.atcr.io" + + # New + managed_holds: + - "did:web:hold01.atcr.io" + - "did:plc:abc123..." + +# New section +billing: + enabled: true + currency: usd + success_url: "{base_url}/settings#storage" + cancel_url: "{base_url}/settings#storage" + tiers: + - name: "Free" + # No stripe_price = free tier + - name: "Standard" + stripe_price_monthly: price_xxx + stripe_price_yearly: price_yyy + - name: "Pro" + stripe_price_monthly: price_xxx + stripe_price_yearly: price_yyy +``` + +### AppView environment additions + +```bash +STRIPE_SECRET_KEY=sk_live_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +``` + +### Hold config changes + +```yaml +# Removed +billing: + # entire section removed from hold config + +# Stays (quota enforcement only) +quota: + tiers: + - name: deckhand + quota: 5GB + - name: bosun + quota: 50GB + - name: quartermaster + quota: 100GB + defaults: + new_crew_tier: deckhand +``` + +The hold no longer has Stripe config. It just defines storage limits per tier and enforces them. + +## AppView DB Schema Additions + +```sql +-- Webhook configurations (moved from hold SQLite) +CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_did TEXT NOT NULL, + url TEXT NOT NULL, + secret_hash TEXT, -- bcrypt hash of HMAC secret + triggers INTEGER NOT NULL DEFAULT 1, -- bitmask: first=1, all=2, changed=4 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_did, url) +); + +-- Billing: track which holds have been attested +CREATE TABLE hold_attestations ( + hold_did TEXT PRIMARY KEY, + attestation_cid TEXT NOT NULL, -- CID of attestation record in hold's PDS + issued_at DATETIME NOT NULL, + verified_at DATETIME +); +``` + +Stripe customer/subscription data continues to live in Stripe (queried via API, cached in memory). No local subscription table needed — same pattern as current hold billing, just on appview. + +## Implementation Phases + +### Phase 1: Trust foundation +- Add `managed_holds` to appview config +- Implement attestation signing (appview) and storage (hold) +- Add attestation verification to hold's tier-update endpoint +- New XRPC endpoint on hold: `io.atcr.hold.updateCrewTier` (appview-authenticated) + +### Phase 2: Billing migration +- Move Stripe integration from hold to appview (reuse `pkg/hold/billing/` code) +- AppView billing uses `-tags billing` build tag (same pattern) +- Implement tier pairing: appview billing slots mapped to hold tier lists +- New appview endpoints: checkout, portal, stripe webhook receiver +- Settings UI: single subscription section (not per-hold) + +### Phase 3: Webhook migration ✅ +- Add webhook + scans tables to appview DB +- Implement webhook dispatch in appview's Jetstream processor +- Move Discord/Slack formatting code to `pkg/appview/webhooks/` +- Deprecate hold webhook XRPC endpoints (X-Deprecated header) +- Webhooks now user-scoped (global across all holds) in appview DB +- Scan records cached from Jetstream for change detection + +### Phase 4: Cleanup ✅ +- Removed hold webhook XRPC endpoints, dispatch code, and `webhooks.go` +- Removed `io.atcr.hold.webhook` and `io.atcr.sailor.webhook` record types + lexicons +- Removed `webhook_secrets` SQLite schema from scan_broadcaster +- Removed `MaxWebhooks`/`WebhookAllTriggers` from hold quota config +- Removed sailor webhook from OAuth scopes + +## Settings UI Impact + +The storage tab simplifies significantly: + +``` +┌──────────────────────────────────────────────────────┐ +│ Active Hold: [▼ hold01.atcr.io (Crew) ] │ +└──────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────┐ +│ Subscription: Standard ($5/mo) [Manage Billing] │ +│ Storage: 3-5 GB depending on region │ +└──────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────┐ +│ ★ hold01.atcr.io [Active] [Crew] [Online] │ +│ Tier: bosun · 281.5 MB / 5.0 GB (5%) │ +│ ▸ Webhooks (2 configured) │ +└──────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────┐ +│ Other Holds Role Status Storage │ +│ hold02.atcr.io Crew ● 230 MB / 3 GB │ +│ hold03.atcr.io Owner ● No data │ +└──────────────────────────────────────────────────────┘ +``` + +Key changes: +- **One subscription section** at the top (not per-hold) +- **Webhooks section** under active hold card (managed by appview now) +- **No "Paid" badge per hold** — subscription is global +- **Storage range** shown on subscription card ("3-5 GB depending on region") +- **Per-hold quota** still shown (each hold enforces its own limit for the user's tier) + +## Open Questions + +1. **Tier list endpoint**: Holds need a new XRPC endpoint that returns their tier list with quotas (without pricing). The appview calls this to build the "3-5 GB depending on region" display. Something like `io.atcr.hold.listTiers`. + +2. **Existing Stripe customers**: Holds with existing Stripe subscriptions need a migration plan. Options: honor existing subscriptions until they expire, or bulk-migrate customers to appview's Stripe account. + +3. **Webhook delivery guarantees**: Moving dispatch to appview adds latency (scan record → Jetstream → appview → webhook). For time-sensitive notifications, consider the hold sending a lightweight "scan completed" signal directly to appview via XRPC rather than waiting for Jetstream propagation. + +4. **Self-hosted appviews**: The attestation model assumes one appview per set of holds. If multiple appviews try to manage the same hold, the hold should only trust the most recent attestation (or maintain a list). diff --git a/docs/HOLD_XRPC_ENDPOINTS.md b/docs/HOLD_XRPC_ENDPOINTS.md index 9b45045..ed82288 100644 --- a/docs/HOLD_XRPC_ENDPOINTS.md +++ b/docs/HOLD_XRPC_ENDPOINTS.md @@ -21,6 +21,7 @@ This document lists all XRPC endpoints implemented in the Hold service (`pkg/hol | `/xrpc/com.atproto.identity.resolveHandle` | GET | Resolve handle to DID | | `/xrpc/app.bsky.actor.getProfile` | GET | Get actor profile | | `/xrpc/app.bsky.actor.getProfiles` | GET | Get multiple profiles | +| `/xrpc/io.atcr.hold.listTiers` | GET | List hold's available tiers with quotas and features | | `/.well-known/did.json` | GET | DID document | | `/.well-known/atproto-did` | GET | DID for handle resolution | @@ -43,10 +44,11 @@ This document lists all XRPC endpoints implemented in the Hold service (`pkg/hol |----------|--------|-------------| | `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership | | `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) | -| `/xrpc/io.atcr.hold.listWebhooks` | GET | List user's webhook configurations | -| `/xrpc/io.atcr.hold.addWebhook` | POST | Add a webhook (tier-gated) | -| `/xrpc/io.atcr.hold.deleteWebhook` | POST | Delete a webhook | -| `/xrpc/io.atcr.hold.testWebhook` | POST | Send test payload to a webhook | +### Appview Token Required + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/xrpc/io.atcr.hold.updateCrewTier` | POST | Update a crew member's tier (appview-only) | --- @@ -78,10 +80,8 @@ All require `blob:write` permission via service token: | `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership | | `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export | | `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info | -| `/xrpc/io.atcr.hold.listWebhooks` | GET | auth | List user's webhook configs | -| `/xrpc/io.atcr.hold.addWebhook` | POST | auth | Add webhook (tier-gated) | -| `/xrpc/io.atcr.hold.deleteWebhook` | POST | auth | Delete a webhook | -| `/xrpc/io.atcr.hold.testWebhook` | POST | auth | Send test payload to webhook | +| `/xrpc/io.atcr.hold.listTiers` | GET | none | List hold's available tiers with quotas and features (scanOnPush) | +| `/xrpc/io.atcr.hold.updateCrewTier` | POST | appview token | Update crew member's tier | --- diff --git a/lexicons/io/atcr/hold/addWebhook.json b/lexicons/io/atcr/hold/addWebhook.json deleted file mode 100644 index 13a0590..0000000 --- a/lexicons/io/atcr/hold/addWebhook.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "io.atcr.hold.addWebhook", - "defs": { - "main": { - "type": "procedure", - "description": "Add a new webhook configuration. Stores URL and optional HMAC secret in hold SQLite, creates an io.atcr.hold.webhook record in the embedded PDS. Enforces tier-based limits on webhook count and trigger types. Requires service token authentication.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["url", "triggers"], - "properties": { - "url": { - "type": "string", - "format": "uri", - "maxLength": 2048, - "description": "HTTPS URL to receive webhook payloads" - }, - "secret": { - "type": "string", - "description": "Optional HMAC-SHA256 signing secret. When set, payloads include an X-Webhook-Signature-256 header.", - "maxLength": 256 - }, - "triggers": { - "type": "integer", - "minimum": 1, - "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["rkey", "cid"], - "properties": { - "rkey": { - "type": "string", - "maxLength": 64, - "description": "Record key of the created io.atcr.hold.webhook record" - }, - "cid": { - "type": "string", - "maxLength": 128, - "description": "CID of the created record (used as privateCid in the sailor webhook record)" - } - } - } - }, - "errors": [ - { "name": "InvalidUrl", "description": "URL is not a valid HTTPS endpoint" }, - { "name": "WebhookLimitReached", "description": "User has reached the maximum number of webhooks for their tier" }, - { "name": "TriggerNotAllowed", "description": "Trigger types beyond scan:first require a paid tier" } - ] - } - } -} diff --git a/lexicons/io/atcr/hold/captain.json b/lexicons/io/atcr/hold/captain.json index 04b3ee9..06d38f3 100644 --- a/lexicons/io/atcr/hold/captain.json +++ b/lexicons/io/atcr/hold/captain.json @@ -41,14 +41,6 @@ "type": "string", "format": "did", "description": "DID of successor hold for migration redirect" - }, - "supporterBadgeTiers": { - "type": "array", - "description": "Tier names that earn a supporter badge on user profiles", - "items": { - "type": "string", - "maxLength": 64 - } } } } diff --git a/lexicons/io/atcr/hold/deleteWebhook.json b/lexicons/io/atcr/hold/deleteWebhook.json deleted file mode 100644 index 7373790..0000000 --- a/lexicons/io/atcr/hold/deleteWebhook.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "io.atcr.hold.deleteWebhook", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a webhook configuration. Removes URL and secret from hold SQLite and deletes the io.atcr.hold.webhook record from the embedded PDS. Only the webhook owner can delete their own webhooks. Requires service token authentication.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["rkey"], - "properties": { - "rkey": { - "type": "string", - "maxLength": 64, - "description": "Record key of the io.atcr.hold.webhook record to delete" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["success"], - "properties": { - "success": { - "type": "boolean", - "description": "Whether the webhook was successfully deleted" - } - } - } - }, - "errors": [ - { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" }, - { "name": "Unauthorized", "description": "Webhook belongs to a different user" } - ] - } - } -} diff --git a/lexicons/io/atcr/hold/listTiers.json b/lexicons/io/atcr/hold/listTiers.json new file mode 100644 index 0000000..a4f2e42 --- /dev/null +++ b/lexicons/io/atcr/hold/listTiers.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "io.atcr.hold.listTiers", + "defs": { + "main": { + "type": "query", + "description": "List the hold's available tiers with storage quotas (no pricing info).", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["tiers"], + "properties": { + "tiers": { + "type": "array", + "items": { + "type": "ref", + "ref": "#defs/tierInfo" + } + } + } + } + } + }, + "tierInfo": { + "type": "object", + "required": ["name", "quotaBytes", "quotaFormatted", "scanOnPush"], + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "description": "Tier name." + }, + "quotaBytes": { + "type": "integer", + "description": "Storage quota in bytes." + }, + "quotaFormatted": { + "type": "string", + "maxLength": 32, + "description": "Human-readable quota (e.g. '5.0 GB')." + }, + "scanOnPush": { + "type": "boolean", + "description": "Whether pushing triggers an immediate vulnerability scan." + } + } + } + } +} diff --git a/lexicons/io/atcr/hold/listWebhooks.json b/lexicons/io/atcr/hold/listWebhooks.json deleted file mode 100644 index 088d860..0000000 --- a/lexicons/io/atcr/hold/listWebhooks.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "lexicon": 1, - "id": "io.atcr.hold.listWebhooks", - "defs": { - "main": { - "type": "query", - "description": "List webhook configurations for a user. Returns masked URLs (never full URLs), trigger settings, and tier-based limits. Requires service token authentication.", - "parameters": { - "type": "params", - "required": ["userDid"], - "properties": { - "userDid": { - "type": "string", - "format": "did", - "description": "DID of the user to list webhooks for" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["webhooks", "limits"], - "properties": { - "webhooks": { - "type": "array", - "description": "List of configured webhooks", - "items": { - "type": "ref", - "ref": "#webhookEntry" - } - }, - "limits": { - "type": "ref", - "ref": "#webhookLimits" - } - } - } - } - }, - "webhookEntry": { - "type": "object", - "required": ["rkey", "triggers", "url", "hasSecret", "createdAt"], - "properties": { - "rkey": { - "type": "string", - "maxLength": 64, - "description": "Record key of the io.atcr.hold.webhook record" - }, - "triggers": { - "type": "integer", - "minimum": 0, - "description": "Bitmask of trigger events" - }, - "url": { - "type": "string", - "maxLength": 2048, - "description": "Masked webhook URL (e.g., https://exam***le.com/web***)" - }, - "hasSecret": { - "type": "boolean", - "description": "Whether the webhook has an HMAC signing secret configured" - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "RFC3339 timestamp of when the webhook was created" - } - } - }, - "webhookLimits": { - "type": "object", - "required": ["max", "allTriggers"], - "properties": { - "max": { - "type": "integer", - "description": "Maximum number of webhooks allowed for this user's tier (-1 for unlimited)" - }, - "allTriggers": { - "type": "boolean", - "description": "Whether the user's tier allows all trigger types (scan:all, scan:changed). Free tiers only get scan:first." - } - } - } - } -} diff --git a/lexicons/io/atcr/hold/testWebhook.json b/lexicons/io/atcr/hold/testWebhook.json deleted file mode 100644 index ca663fc..0000000 --- a/lexicons/io/atcr/hold/testWebhook.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "lexicon": 1, - "id": "io.atcr.hold.testWebhook", - "defs": { - "main": { - "type": "procedure", - "description": "Send a test payload to a webhook URL. Delivers a synthetic scan result synchronously and reports whether delivery succeeded (2xx response). Only the webhook owner can test their own webhooks. Requires service token authentication.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["rkey"], - "properties": { - "rkey": { - "type": "string", - "maxLength": 64, - "description": "Record key of the io.atcr.hold.webhook to test" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["success"], - "properties": { - "success": { - "type": "boolean", - "description": "Whether the test delivery received a 2xx response" - } - } - } - }, - "errors": [ - { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" }, - { "name": "Unauthorized", "description": "Webhook belongs to a different user" } - ] - } - } -} diff --git a/lexicons/io/atcr/hold/updateCrewTier.json b/lexicons/io/atcr/hold/updateCrewTier.json new file mode 100644 index 0000000..5c4da95 --- /dev/null +++ b/lexicons/io/atcr/hold/updateCrewTier.json @@ -0,0 +1,53 @@ +{ + "lexicon": 1, + "id": "io.atcr.hold.updateCrewTier", + "defs": { + "main": { + "type": "procedure", + "description": "Update a crew member's tier. Only accepts requests from the trusted appview.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["userDid", "tierRank"], + "properties": { + "userDid": { + "type": "string", + "format": "did", + "description": "DID of the crew member whose tier is being updated." + }, + "tierRank": { + "type": "integer", + "minimum": 0, + "description": "Tier rank index (0-based, maps to hold tier list by position)." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["tierName"], + "properties": { + "tierName": { + "type": "string", + "maxLength": 64, + "description": "Resolved tier name on this hold." + } + } + } + }, + "errors": [ + { + "name": "AuthRequired", + "description": "Valid appview token required." + }, + { + "name": "UserNotFound", + "description": "User is not a crew member on this hold." + } + ] + } + } +} diff --git a/lexicons/io/atcr/hold/webhook.json b/lexicons/io/atcr/hold/webhook.json deleted file mode 100644 index eea0fb1..0000000 --- a/lexicons/io/atcr/hold/webhook.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lexicon": 1, - "id": "io.atcr.hold.webhook", - "defs": { - "main": { - "type": "record", - "key": "any", - "description": "Webhook configuration stored in the hold's embedded PDS. The public portion of a two-record split: URL and HMAC secret are stored only in the hold's SQLite database (never in ATProto records). Record key is deterministic from user DID + sequence number.", - "record": { - "type": "object", - "required": ["userDid", "triggers", "createdAt"], - "properties": { - "userDid": { - "type": "string", - "format": "did", - "description": "DID of the webhook owner" - }, - "triggers": { - "type": "integer", - "minimum": 0, - "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "RFC3339 timestamp of when the webhook was created" - } - } - } - } - } -} diff --git a/lexicons/io/atcr/sailor/webhook.json b/lexicons/io/atcr/sailor/webhook.json deleted file mode 100644 index f55b909..0000000 --- a/lexicons/io/atcr/sailor/webhook.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "lexicon": 1, - "id": "io.atcr.sailor.webhook", - "defs": { - "main": { - "type": "record", - "key": "tid", - "description": "Public webhook metadata stored in the user's PDS. Links to a private io.atcr.hold.webhook record on the hold where URL and secret are stored. Part of a two-record split: this record is visible via ATProto (Jetstream), the hold record is not.", - "record": { - "type": "object", - "required": ["holdDid", "triggers", "privateCid", "createdAt"], - "properties": { - "holdDid": { - "type": "string", - "format": "did", - "description": "DID of the hold where the webhook is configured" - }, - "triggers": { - "type": "integer", - "minimum": 0, - "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" - }, - "privateCid": { - "type": "string", - "maxLength": 128, - "description": "CID of the corresponding io.atcr.hold.webhook record on the hold" - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "RFC3339 timestamp of when the webhook was created" - }, - "updatedAt": { - "type": "string", - "format": "datetime", - "description": "RFC3339 timestamp of when the webhook was last updated" - } - } - } - } - } -} diff --git a/lexicons/io/atcr/tag.json b/lexicons/io/atcr/tag.json index a3c0b5e..a5035f2 100644 --- a/lexicons/io/atcr/tag.json +++ b/lexicons/io/atcr/tag.json @@ -8,7 +8,7 @@ "key": "any", "record": { "type": "object", - "required": ["repository", "tag", "createdAt"], + "required": ["repository", "tag"], "properties": { "repository": { "type": "string", @@ -30,10 +30,10 @@ "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.", "maxLength": 128 }, - "createdAt": { + "updatedAt": { "type": "string", "format": "datetime", - "description": "Tag creation timestamp" + "description": "Timestamp of last tag update" } } } diff --git a/pkg/appview/config.go b/pkg/appview/config.go index 898b574..64fe75a 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -16,6 +16,7 @@ import ( "github.com/distribution/distribution/v3/configuration" "github.com/spf13/viper" + "atcr.io/pkg/billing" "atcr.io/pkg/config" ) @@ -31,6 +32,7 @@ type Config struct { Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."` Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` + Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility } @@ -59,6 +61,9 @@ type ServerConfig struct { // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display). RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."` + + // DIDs of holds this appview manages billing for. + ManagedHolds []string `yaml:"managed_holds" comment:"DIDs of holds this appview manages billing for. Tier updates are pushed to these holds."` } // UIConfig defines web UI settings @@ -97,6 +102,9 @@ type JetstreamConfig struct { // Sync existing records from PDS on startup. BackfillEnabled bool `yaml:"backfill_enabled" comment:"Sync existing records from PDS on startup."` + // How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup. + BackfillInterval time.Duration `yaml:"backfill_interval" comment:"How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup."` + // Relay endpoints for backfill, tried in order on failure. RelayEndpoints []string `yaml:"relay_endpoints" comment:"Relay endpoints for backfill, tried in order on failure."` } @@ -146,6 +154,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.client_short_name", "ATCR") v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key") v.SetDefault("server.registry_domains", []string{}) + v.SetDefault("server.managed_holds", []string{}) // UI defaults v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db") @@ -166,6 +175,7 @@ func setDefaults(v *viper.Viper) { "wss://jetstream1.us-east.bsky.network/subscribe", }) v.SetDefault("jetstream.backfill_enabled", true) + v.SetDefault("jetstream.backfill_interval", "24h") v.SetDefault("jetstream.relay_endpoints", []string{ "https://relay1.us-east.bsky.network", "https://relay1.us-west.bsky.network", @@ -199,7 +209,20 @@ func DefaultConfig() *Config { // ExampleYAML returns a fully-commented YAML configuration with default values. func ExampleYAML() ([]byte, error) { - return config.MarshalCommentedYAML("ATCR AppView Configuration", DefaultConfig()) + cfg := DefaultConfig() + + // Populate example billing tiers so operators see the structure + cfg.Billing.Currency = "usd" + cfg.Billing.SuccessURL = "{base_url}/settings#storage" + cfg.Billing.CancelURL = "{base_url}/settings#storage" + cfg.Billing.OwnerBadge = true + cfg.Billing.Tiers = []billing.BillingTierConfig{ + {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1}, + {Name: "bosun", Description: "More storage with scan-on-push", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true}, + {Name: "quartermaster", Description: "Maximum storage for power users", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true}, + } + + return config.MarshalCommentedYAML("ATCR AppView Configuration", cfg) } // LoadConfig builds a complete configuration using Viper layered loading: diff --git a/pkg/appview/db/annotations.go b/pkg/appview/db/annotations.go index d2554e7..0d110d1 100644 --- a/pkg/appview/db/annotations.go +++ b/pkg/appview/db/annotations.go @@ -1,6 +1,9 @@ package db -import "time" +import ( + "strings" + "time" +) // GetRepositoryAnnotations retrieves all annotations for a repository func GetRepositoryAnnotations(db DBTX, did, repository string) (map[string]string, error) { @@ -26,23 +29,44 @@ func GetRepositoryAnnotations(db DBTX, did, repository string) (map[string]strin return annotations, rows.Err() } -// UpsertRepositoryAnnotations replaces all annotations for a repository +// UpsertRepositoryAnnotations upserts annotations for a repository. +// Stale keys not present in the new map are deleted. +// Unchanged values are skipped to avoid unnecessary writes. // Only called when manifest has at least one non-empty annotation. // Atomicity is provided by the caller's transaction when used during backfill. func UpsertRepositoryAnnotations(db DBTX, did, repository string, annotations map[string]string) error { - // Delete existing annotations + // Delete keys that are no longer in the annotation set + if len(annotations) == 0 { + _, err := db.Exec(` + DELETE FROM repository_annotations + WHERE did = ? AND repository = ? + `, did, repository) + return err + } + + // Build placeholders for the NOT IN clause + placeholders := make([]string, 0, len(annotations)) + args := []any{did, repository} + for key := range annotations { + placeholders = append(placeholders, "?") + args = append(args, key) + } _, err := db.Exec(` DELETE FROM repository_annotations - WHERE did = ? AND repository = ? - `, did, repository) + WHERE did = ? AND repository = ? AND key NOT IN (`+strings.Join(placeholders, ",")+`) + `, args...) if err != nil { return err } - // Insert new annotations + // Upsert each annotation, only writing when value changed stmt, err := db.Prepare(` INSERT INTO repository_annotations (did, repository, key, value, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(did, repository, key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + WHERE excluded.value != repository_annotations.value `) if err != nil { return err diff --git a/pkg/appview/db/hold_store.go b/pkg/appview/db/hold_store.go index 9b5e8ff..3b3faaa 100644 --- a/pkg/appview/db/hold_store.go +++ b/pkg/appview/db/hold_store.go @@ -2,7 +2,6 @@ package db import ( "database/sql" - "encoding/json" "fmt" "strings" "time" @@ -20,15 +19,14 @@ func normalizeDateString(s string) string { // HoldCaptainRecord represents a cached captain record from a hold's PDS type HoldCaptainRecord struct { - HoldDID string `json:"-"` // Set manually, not from JSON - OwnerDID string `json:"owner"` - Public bool `json:"public"` - AllowAllCrew bool `json:"allowAllCrew"` - DeployedAt string `json:"deployedAt"` - Region string `json:"region"` - Successor string `json:"successor"` // DID of successor hold (migration redirect) - SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]' - UpdatedAt time.Time `json:"-"` // Set manually, not from JSON + HoldDID string `json:"-"` // Set manually, not from JSON + OwnerDID string `json:"owner"` + Public bool `json:"public"` + AllowAllCrew bool `json:"allowAllCrew"` + DeployedAt string `json:"deployedAt"` + Region string `json:"region"` + Successor string `json:"successor"` // DID of successor hold (migration redirect) + UpdatedAt time.Time `json:"-"` // Set manually, not from JSON } // GetCaptainRecord retrieves a captain record from the cache @@ -36,13 +34,13 @@ type HoldCaptainRecord struct { func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) { query := ` SELECT hold_did, owner_did, public, allow_all_crew, - deployed_at, region, successor, supporter_badge_tiers, updated_at + deployed_at, region, successor, updated_at FROM hold_captain_records WHERE hold_did = ? ` var record HoldCaptainRecord - var deployedAt, region, successor, supporterBadgeTiers sql.NullString + var deployedAt, region, successor sql.NullString err := db.QueryRow(query, holdDID).Scan( &record.HoldDID, @@ -52,7 +50,6 @@ func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) { &deployedAt, ®ion, &successor, - &supporterBadgeTiers, &record.UpdatedAt, ) @@ -74,9 +71,6 @@ func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) { if successor.Valid { record.Successor = successor.String } - if supporterBadgeTiers.Valid { - record.SupporterBadgeTiers = supporterBadgeTiers.String - } return &record, nil } @@ -86,8 +80,8 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error { query := ` INSERT INTO hold_captain_records ( hold_did, owner_did, public, allow_all_crew, - deployed_at, region, successor, supporter_badge_tiers, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + deployed_at, region, successor, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(hold_did) DO UPDATE SET owner_did = excluded.owner_did, public = excluded.public, @@ -95,8 +89,13 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error { deployed_at = excluded.deployed_at, region = excluded.region, successor = excluded.successor, - supporter_badge_tiers = excluded.supporter_badge_tiers, updated_at = excluded.updated_at + WHERE excluded.owner_did != hold_captain_records.owner_did + OR excluded.public != hold_captain_records.public + OR excluded.allow_all_crew != hold_captain_records.allow_all_crew + OR excluded.deployed_at IS NOT hold_captain_records.deployed_at + OR excluded.region IS NOT hold_captain_records.region + OR excluded.successor IS NOT hold_captain_records.successor ` _, err := db.Exec(query, @@ -107,7 +106,6 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error { nullString(record.DeployedAt), nullString(record.Region), nullString(record.Successor), - nullString(record.SupporterBadgeTiers), record.UpdatedAt, ) @@ -118,75 +116,6 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error { return nil } -// HasSupporterBadge checks if a given tier is in the hold's supporter badge tiers list. -func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool { - if r.SupporterBadgeTiers == "" || tier == "" { - return false - } - var tiers []string - if err := json.Unmarshal([]byte(r.SupporterBadgeTiers), &tiers); err != nil { - return false - } - for _, t := range tiers { - if t == tier { - return true - } - } - return false -} - -// normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators. -// This is a local copy to avoid importing atproto (prevents circular dependencies). -func normalizeDidWeb(did string) string { - if !strings.HasPrefix(did, "did:web:") { - return did - } - host := strings.TrimPrefix(did, "did:web:") - if !strings.Contains(host, "%3A") && strings.Contains(host, ":") { - host = strings.Replace(host, ":", "%3A", 1) - } - return "did:web:" + host -} - -// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold. -// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible, -// or the user isn't a member of the hold. -func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string { - if holdDID == "" || userDID == "" { - return "" - } - - // Normalize did:web encoding for consistent comparison - holdDID = normalizeDidWeb(holdDID) - - captain, err := GetCaptainRecord(dbConn, holdDID) - if err != nil || captain == nil || captain.SupporterBadgeTiers == "" { - return "" - } - - // If user is the owner and "owner" badge is enabled, show it - if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") { - return "owner" - } - - // Look up crew membership for this user on this hold - memberships, err := GetCrewMemberships(dbConn, userDID) - if err != nil { - return "" - } - - for _, m := range memberships { - if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" { - if captain.HasSupporterBadge(m.Tier) { - return m.Tier - } - return "" - } - } - - return "" -} - // GetCrewHoldDID returns the hold DID from the user's most recent crew membership. // Used as a fallback when the user's DefaultHoldDID is not cached. func GetCrewHoldDID(db DBTX, memberDID string) string { @@ -342,6 +271,11 @@ func UpsertCrewMember(db DBTX, member *CrewMember) error { tier = excluded.tier, added_at = excluded.added_at, updated_at = CURRENT_TIMESTAMP + WHERE excluded.rkey != hold_crew_members.rkey + OR excluded.role IS NOT hold_crew_members.role + OR excluded.permissions IS NOT hold_crew_members.permissions + OR excluded.tier IS NOT hold_crew_members.tier + OR excluded.added_at IS NOT hold_crew_members.added_at ` _, err := db.Exec(query, diff --git a/pkg/appview/db/migrations/0015_create_webhooks_and_scans.yaml b/pkg/appview/db/migrations/0015_create_webhooks_and_scans.yaml new file mode 100644 index 0000000..45b28d0 --- /dev/null +++ b/pkg/appview/db/migrations/0015_create_webhooks_and_scans.yaml @@ -0,0 +1,27 @@ +description: Add webhooks and scans tables for appview-side webhook management and scan caching +query: | + CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + user_did TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT, + triggers INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did); + CREATE TABLE IF NOT EXISTS scans ( + hold_did TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + user_did TEXT NOT NULL, + repository TEXT NOT NULL, + critical INTEGER NOT NULL DEFAULT 0, + high INTEGER NOT NULL DEFAULT 0, + medium INTEGER NOT NULL DEFAULT 0, + low INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + scanner_version TEXT, + scanned_at TIMESTAMP NOT NULL, + PRIMARY KEY(hold_did, manifest_digest) + ); + CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did); diff --git a/pkg/appview/db/migrations/0016_drop_supporter_badge_tiers.yaml b/pkg/appview/db/migrations/0016_drop_supporter_badge_tiers.yaml new file mode 100644 index 0000000..ccb2cf4 --- /dev/null +++ b/pkg/appview/db/migrations/0016_drop_supporter_badge_tiers.yaml @@ -0,0 +1,3 @@ +description: Drop supporter_badge_tiers column (badges now determined by appview billing config) +query: | + ALTER TABLE hold_captain_records DROP COLUMN supporter_badge_tiers; diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 88f15ea..69b7291 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "net/url" "strings" "time" ) @@ -369,6 +370,17 @@ func GetUserByHandle(db DBTX, handle string) (*User, error) { return &user, nil } +// InsertUserIfNotExists inserts a user record only if it doesn't already exist. +// Used by non-profile collections to avoid unnecessary writes during backfill. +func InsertUserIfNotExists(db DBTX, user *User) error { + _, err := db.Exec(` + INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(did) DO NOTHING + `, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen) + return err +} + // UpsertUser inserts or updates a user record func UpsertUser(db DBTX, user *User) error { _, err := db.Exec(` @@ -592,6 +604,12 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) { config_digest = excluded.config_digest, config_size = excluded.config_size, artifact_type = excluded.artifact_type + WHERE excluded.hold_endpoint != manifests.hold_endpoint + OR excluded.schema_version != manifests.schema_version + OR excluded.media_type != manifests.media_type + OR excluded.config_digest IS NOT manifests.config_digest + OR excluded.config_size IS NOT manifests.config_size + OR excluded.artifact_type != manifests.artifact_type `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt) @@ -614,8 +632,8 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) { return id, nil } -// InsertLayer inserts or updates a layer record. -// Uses upsert so backfill re-processing populates new columns (e.g. annotations). +// InsertLayer inserts a layer record, skipping if it already exists. +// Layers are immutable — once created, their digest/size/media_type never change. func InsertLayer(db DBTX, layer *Layer) error { var annotationsJSON *string if len(layer.Annotations) > 0 { @@ -629,11 +647,7 @@ func InsertLayer(db DBTX, layer *Layer) error { _, err := db.Exec(` INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(manifest_id, layer_index) DO UPDATE SET - digest = excluded.digest, - size = excluded.size, - media_type = excluded.media_type, - annotations = excluded.annotations + ON CONFLICT(manifest_id, layer_index) DO NOTHING `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON) return err } @@ -646,6 +660,8 @@ func UpsertTag(db DBTX, tag *Tag) error { ON CONFLICT(did, repository, tag) DO UPDATE SET digest = excluded.digest, created_at = excluded.created_at + WHERE excluded.digest != tags.digest + OR excluded.created_at != tags.created_at `, tag.DID, tag.Repository, tag.Tag, tag.Digest, tag.CreatedAt) return err } @@ -1607,17 +1623,21 @@ func UpsertRepositoryStats(db DBTX, stats *RepositoryStats) error { last_pull = excluded.last_pull, push_count = excluded.push_count, last_push = excluded.last_push + WHERE excluded.pull_count != repository_stats.pull_count + OR excluded.last_pull IS NOT repository_stats.last_pull + OR excluded.push_count != repository_stats.push_count + OR excluded.last_push IS NOT repository_stats.last_push `, stats.DID, stats.Repository, stats.PullCount, stats.LastPull, stats.PushCount, stats.LastPush) return err } -// UpsertStar inserts or updates a star record (idempotent) +// UpsertStar inserts a star record, skipping if it already exists. +// Stars are immutable — once created, they don't change. func UpsertStar(db DBTX, starrerDID, ownerDID, repository string, createdAt time.Time) error { _, err := db.Exec(` INSERT INTO stars (starrer_did, owner_did, repository, created_at) VALUES (?, ?, ?, ?) - ON CONFLICT(starrer_did, owner_did, repository) DO UPDATE SET - created_at = excluded.created_at + ON CONFLICT(starrer_did, owner_did, repository) DO NOTHING `, starrerDID, ownerDID, repository, createdAt) return err } @@ -1957,6 +1977,8 @@ func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, cre description = excluded.description, avatar_cid = excluded.avatar_cid, updated_at = excluded.updated_at + WHERE excluded.description IS NOT repo_pages.description + OR excluded.avatar_cid IS NOT repo_pages.avatar_cid `, did, repository, description, avatarCID, createdAt, updatedAt) return err } @@ -2005,3 +2027,234 @@ func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) { } return pages, rows.Err() } + +// --- Webhook types and queries --- + +// Webhook represents a webhook configuration stored in the appview DB +type Webhook struct { + ID string `json:"id"` + UserDID string `json:"userDid"` + URL string `json:"url"` + Secret string `json:"-"` + Triggers int `json:"triggers"` + HasSecret bool `json:"hasSecret"` + CreatedAt time.Time `json:"createdAt"` +} + +// CountWebhooks returns the number of webhooks configured for a user +func CountWebhooks(db DBTX, userDID string) (int, error) { + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM webhooks WHERE user_did = ?`, userDID).Scan(&count) + return count, err +} + +// ListWebhooks returns webhook configurations for display (masked URLs, no secrets) +func ListWebhooks(db DBTX, userDID string) ([]Webhook, error) { + rows, err := db.Query(` + SELECT id, user_did, url, secret, triggers, created_at + FROM webhooks WHERE user_did = ? ORDER BY created_at ASC + `, userDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var webhooks []Webhook + for rows.Next() { + var w Webhook + var secret string + if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &secret, &w.Triggers, &w.CreatedAt); err != nil { + continue + } + w.HasSecret = secret != "" + w.URL = maskWebhookURL(w.URL) + webhooks = append(webhooks, w) + } + if webhooks == nil { + webhooks = []Webhook{} + } + return webhooks, rows.Err() +} + +// GetWebhookByID returns a single webhook with full URL and secret (for dispatch/test) +func GetWebhookByID(db DBTX, id string) (*Webhook, error) { + var w Webhook + err := db.QueryRow(` + SELECT id, user_did, url, secret, triggers, created_at + FROM webhooks WHERE id = ? + `, id).Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt) + if err != nil { + return nil, err + } + w.HasSecret = w.Secret != "" + return &w, nil +} + +// InsertWebhook creates a new webhook record +func InsertWebhook(db DBTX, w *Webhook) error { + _, err := db.Exec(` + INSERT INTO webhooks (id, user_did, url, secret, triggers, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, w.ID, w.UserDID, w.URL, w.Secret, w.Triggers, w.CreatedAt) + return err +} + +// DeleteWebhook deletes a webhook by ID, validating ownership +func DeleteWebhook(db DBTX, id, userDID string) error { + result, err := db.Exec(`DELETE FROM webhooks WHERE id = ? AND user_did = ?`, id, userDID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("webhook not found or not owned by user") + } + return nil +} + +// GetWebhooksForUser returns all webhooks with full URL+secret for dispatch +func GetWebhooksForUser(db DBTX, userDID string) ([]Webhook, error) { + rows, err := db.Query(` + SELECT id, user_did, url, secret, triggers, created_at + FROM webhooks WHERE user_did = ? + `, userDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var webhooks []Webhook + for rows.Next() { + var w Webhook + if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt); err != nil { + continue + } + w.HasSecret = w.Secret != "" + webhooks = append(webhooks, w) + } + return webhooks, rows.Err() +} + +// maskWebhookURL masks a URL for display (shows scheme + host, hides path/query) +func maskWebhookURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + if len(rawURL) > 30 { + return rawURL[:30] + "***" + } + return rawURL + } + masked := u.Scheme + "://" + u.Host + if u.Path != "" && u.Path != "/" { + masked += "/***" + } + return masked +} + +// --- Scan types and queries --- + +// Scan represents a cached scan record from Jetstream +type Scan struct { + HoldDID string + ManifestDigest string + UserDID string + Repository string + Critical int + High int + Medium int + Low int + Total int + ScannerVersion string + ScannedAt time.Time +} + +// UpsertScan inserts or updates a scan record, returning the previous scan for change detection +func UpsertScan(db DBTX, scan *Scan) (*Scan, error) { + // Fetch previous scan (if any) before upserting + var prev *Scan + var p Scan + err := db.QueryRow(` + SELECT hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at + FROM scans WHERE hold_did = ? AND manifest_digest = ? + `, scan.HoldDID, scan.ManifestDigest).Scan( + &p.HoldDID, &p.ManifestDigest, &p.UserDID, &p.Repository, + &p.Critical, &p.High, &p.Medium, &p.Low, &p.Total, + &p.ScannerVersion, &p.ScannedAt, + ) + if err == nil { + prev = &p + } + + // Upsert the new scan + _, err = db.Exec(` + INSERT INTO scans (hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(hold_did, manifest_digest) DO UPDATE SET + user_did = excluded.user_did, + repository = excluded.repository, + critical = excluded.critical, + high = excluded.high, + medium = excluded.medium, + low = excluded.low, + total = excluded.total, + scanner_version = excluded.scanner_version, + scanned_at = excluded.scanned_at + WHERE excluded.critical != scans.critical + OR excluded.high != scans.high + OR excluded.medium != scans.medium + OR excluded.low != scans.low + OR excluded.total != scans.total + OR excluded.scanner_version IS NOT scans.scanner_version + OR excluded.scanned_at != scans.scanned_at + `, scan.HoldDID, scan.ManifestDigest, scan.UserDID, scan.Repository, + scan.Critical, scan.High, scan.Medium, scan.Low, scan.Total, + scan.ScannerVersion, scan.ScannedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to upsert scan: %w", err) + } + + return prev, nil +} + +// GetTagByDigest returns the most recent tag for a manifest digest in a user's repository +func GetTagByDigest(db DBTX, userDID, repository, digest string) (string, error) { + var tag string + err := db.QueryRow(` + SELECT tag FROM tags + WHERE did = ? AND repository = ? AND digest = ? + ORDER BY created_at DESC LIMIT 1 + `, userDID, repository, digest).Scan(&tag) + if err != nil { + return "", err + } + return tag, nil +} + +// IsHoldCaptain returns true if userDID is the owner of any hold in the managedHolds list. +func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error) { + if userDID == "" || len(managedHolds) == 0 { + return false, nil + } + + placeholders := make([]string, len(managedHolds)) + args := make([]any, 0, len(managedHolds)+1) + args = append(args, userDID) + for i, did := range managedHolds { + placeholders[i] = "?" + args = append(args, did) + } + + var exists int + err := db.QueryRow( + `SELECT 1 FROM hold_captain_records WHERE owner_did = ? AND hold_did IN (`+strings.Join(placeholders, ",")+`) LIMIT 1`, + args..., + ).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/appview/db/schema.sql b/pkg/appview/db/schema.sql index c1db331..e5e3427 100644 --- a/pkg/appview/db/schema.sql +++ b/pkg/appview/db/schema.sql @@ -186,7 +186,6 @@ CREATE TABLE IF NOT EXISTS hold_captain_records ( deployed_at TEXT, region TEXT, successor TEXT, - supporter_badge_tiers TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at); @@ -245,3 +244,30 @@ CREATE TABLE IF NOT EXISTS crypto_keys ( key_data BLOB NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + user_did TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT, + triggers INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did); + +CREATE TABLE IF NOT EXISTS scans ( + hold_did TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + user_did TEXT NOT NULL, + repository TEXT NOT NULL, + critical INTEGER NOT NULL DEFAULT 0, + high INTEGER NOT NULL DEFAULT 0, + medium INTEGER NOT NULL DEFAULT 0, + low INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + scanner_version TEXT, + scanned_at TIMESTAMP NOT NULL, + PRIMARY KEY(hold_did, manifest_digest) +); +CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did); diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index a69ece3..18bc070 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -7,7 +7,9 @@ import ( "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/holdhealth" "atcr.io/pkg/appview/readme" + "atcr.io/pkg/appview/webhooks" "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/billing" "github.com/bluesky-social/indigo/atproto/identity" ) @@ -25,10 +27,12 @@ type BaseUIHandler struct { ReadOnlyDB *sql.DB // Read-only access // Services - Refresher *oauth.Refresher - HealthChecker *holdhealth.Checker - ReadmeFetcher *readme.Fetcher - Directory identity.Directory + Refresher *oauth.Refresher + HealthChecker *holdhealth.Checker + ReadmeFetcher *readme.Fetcher + Directory identity.Directory + BillingManager *billing.Manager + WebhookDispatcher *webhooks.Dispatcher // Stores SessionStore *db.SessionStore diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index d5ebe26..5e2e4ae 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -4,7 +4,7 @@ import ( "context" "database/sql" "encoding/json" - "html/template" + "fmt" "log/slog" "net/http" "net/url" @@ -14,7 +14,9 @@ import ( "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/appview/storage" + "atcr.io/pkg/appview/webhooks" "atcr.io/pkg/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" ) @@ -26,6 +28,7 @@ type HoldDisplay struct { Membership string `json:"membership"` Permissions []string `json:"permissions,omitempty"` Status string `json:"status"` // "" = unknown, "online", "offline" + IsActive bool `json:"isActive"` } // SettingsHandler handles the settings page @@ -61,22 +64,22 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold) - // Get available holds for dropdown - var ownedHolds, crewHolds, eligibleHolds []HoldDisplay - holdDataMap := make(map[string]HoldDisplay) + // Get available holds + var activeHold *HoldDisplay + var otherHolds, allHolds []HoldDisplay if h.DB != nil { availableHolds, err := db.GetAvailableHolds(h.DB, user.DID) if err != nil { slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err) } else { - // Group holds by membership type for _, hold := range availableHolds { display := HoldDisplay{ DID: hold.HoldDID, DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID), Region: hold.Region, Membership: hold.Membership, + IsActive: hold.HoldDID == profile.DefaultHold, } // Parse permissions JSON if present @@ -86,9 +89,9 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - // Check cached health status (non-blocking, nil = no data yet) + // Check health status (uses cache if available, otherwise pings on-demand) if h.HealthChecker != nil { - if status := h.HealthChecker.GetCachedStatus(hold.HoldDID); status != nil { + if status := h.HealthChecker.GetStatus(r.Context(), hold.HoldDID); status != nil { if status.Reachable { display.Status = "online" } else { @@ -97,37 +100,27 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - // Add to data map for JavaScript - holdDataMap[hold.HoldDID] = display + // All holds go in dropdown list + allHolds = append(allHolds, display) - // Group by membership type - switch hold.Membership { - case "owner": - ownedHolds = append(ownedHolds, display) - case "crew": - crewHolds = append(crewHolds, display) - case "eligible": - eligibleHolds = append(eligibleHolds, display) + // Separate active from other member holds (skip eligible) + if hold.Membership != "eligible" { + if display.IsActive { + holdCopy := display + activeHold = &holdCopy + } else { + otherHolds = append(otherHolds, display) + } } } } } - // Serialize hold data for JavaScript - holdDataJSON, _ := json.Marshal(holdDataMap) + // Fetch webhooks (local DB read) + webhooksData := h.buildWebhooksData(user.DID) - // Check if current hold needs to be shown separately (not in discovered holds) - _, currentHoldDiscovered := holdDataMap[profile.DefaultHold] - showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered - - // Look up AppView default hold details from database - appViewDefaultDisplay := resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, h.DefaultHoldDID) - var appViewDefaultRegion string - if h.DefaultHoldDID != "" && h.DB != nil { - if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil { - appViewDefaultRegion = captain.Region - } - } + // Fetch subscription info (Stripe with in-memory cache) + subscriptionData := h.buildSubscriptionDisplay(user.DID) meta := NewPageMeta( "Settings - "+h.ClientShortName, @@ -144,29 +137,19 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { PDSEndpoint string DefaultHold string } - CurrentHoldDID string - CurrentHoldDisplay string - ShowCurrentHold bool - AppViewDefaultHoldDID string - AppViewDefaultHoldDisplay string - AppViewDefaultRegion string - OwnedHolds []HoldDisplay - CrewHolds []HoldDisplay - EligibleHolds []HoldDisplay - HoldDataJSON template.JS + ActiveHold *HoldDisplay + OtherHolds []HoldDisplay + AllHolds []HoldDisplay + WebhooksData webhooksTemplateData + Subscription SubscriptionDisplay }{ - PageData: NewPageData(r, &h.BaseUIHandler), - Meta: meta, - CurrentHoldDID: profile.DefaultHold, - CurrentHoldDisplay: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, profile.DefaultHold), - ShowCurrentHold: showCurrentHold, - AppViewDefaultHoldDID: h.DefaultHoldDID, - AppViewDefaultHoldDisplay: appViewDefaultDisplay, - AppViewDefaultRegion: appViewDefaultRegion, - OwnedHolds: ownedHolds, - CrewHolds: crewHolds, - EligibleHolds: eligibleHolds, - HoldDataJSON: template.JS(holdDataJSON), + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + ActiveHold: activeHold, + OtherHolds: otherHolds, + AllHolds: allHolds, + WebhooksData: webhooksData, + Subscription: subscriptionData, } data.Profile.Handle = user.Handle @@ -180,6 +163,97 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// webhooksTemplateData is the data passed to the webhooks_list template. +type webhooksTemplateData struct { + Webhooks []webhookEntry + Limits webhookLimits + ContainerID string + TriggerInfo []triggerInfo +} + +// buildWebhooksData fetches webhook data for SSR in the settings page. +func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData { + data := webhooksTemplateData{ + ContainerID: "webhooks-content", + TriggerInfo: []triggerInfo{ + {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, + {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, + {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, + }, + } + + maxWebhooks, allTriggers := h.getWebhookLimits(userDID) + data.Limits = webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers} + + webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID) + if err != nil { + slog.Warn("Failed to list webhooks for settings SSR", "error", err) + return data + } + + data.Webhooks = make([]webhookEntry, len(webhookList)) + for i, wh := range webhookList { + data.Webhooks[i] = webhookEntry{ + ID: wh.ID, + Triggers: wh.Triggers, + URL: wh.URL, + HasSecret: wh.HasSecret, + CreatedAt: wh.CreatedAt.Format(time.RFC3339), + HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, + HasAll: wh.Triggers&webhooks.TriggerAll != 0, + HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, + } + } + + return data +} + +// buildSubscriptionDisplay fetches subscription info for SSR in the settings page. +func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionDisplay { + if h.BillingManager == nil || !h.BillingManager.Enabled() { + return SubscriptionDisplay{HideBilling: true} + } + + info, err := h.BillingManager.GetSubscriptionInfo(userDID) + if err != nil { + slog.Warn("Failed to get subscription info for settings SSR", "did", userDID, "error", err) + return SubscriptionDisplay{HideBilling: true} + } + + if !info.PaymentsEnabled { + return SubscriptionDisplay{HideBilling: true} + } + + display := SubscriptionDisplay{ + UserDID: info.UserDID, + CurrentTier: info.CurrentTier, + PaymentsEnabled: info.PaymentsEnabled, + SubscriptionID: info.SubscriptionID, + BillingInterval: info.BillingInterval, + } + + for _, tier := range info.Tiers { + td := TierDisplay{ + ID: tier.ID, + Name: tier.Name, + Description: tier.Description, + Features: tier.Features, + PriceCentsMonthly: tier.PriceCentsMonthly, + PriceCentsYearly: tier.PriceCentsYearly, + IsCurrent: tier.IsCurrent, + } + if tier.PriceCentsMonthly > 0 { + td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100) + } + if tier.PriceCentsYearly > 0 { + td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100) + } + display.Tiers = append(display.Tiers, td) + } + + return display +} + // resolveHoldDisplayName resolves a hold DID to a human-readable handle via the // identity directory. Falls back to domain extraction (did:web) or truncation (did:plc). func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) string { @@ -302,6 +376,7 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ } } + w.Header().Set("HX-Refresh", "true") w.Header().Set("Content-Type", "text/html") if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ "Type": "success", @@ -340,22 +415,12 @@ func refreshCaptainRecord(holdDID string, dbConn *sql.DB) { captainRecord.HoldDID = holdDID captainRecord.UpdatedAt = time.Now() - // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-") - var raw struct { - SupporterBadgeTiers []string `json:"supporterBadgeTiers"` - } - if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 { - if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil { - captainRecord.SupporterBadgeTiers = string(jsonBytes) - } - } - if err := db.UpsertCaptainRecord(dbConn, &captainRecord); err != nil { slog.Debug("Failed to cache captain record on refresh", "hold_did", holdDID, "error", err) return } - slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers) + slog.Info("Refreshed captain record for hold", "hold_did", holdDID) } // refreshCrewMembership fetches a user's crew record from a hold and caches it locally. diff --git a/pkg/appview/handlers/storage.go b/pkg/appview/handlers/storage.go index 50db43f..bc6fef8 100644 --- a/pkg/appview/handlers/storage.go +++ b/pkg/appview/handlers/storage.go @@ -6,7 +6,6 @@ import ( "log/slog" "net/http" - "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/appview/storage" "atcr.io/pkg/atproto" @@ -83,10 +82,23 @@ func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Render the stats partial + // Render compact or full stats + if r.URL.Query().Get("compact") == "true" { + h.renderCompact(w, stats) + return + } h.renderStats(w, stats, holdDID) } +func (h *StorageHandler) renderCompact(w http.ResponseWriter, stats QuotaStats) { + w.Header().Set("Content-Type", "text/html") + if stats.TotalSize == 0 && stats.UniqueBlobs == 0 { + fmt.Fprint(w, `No data`) + } else { + fmt.Fprint(w, humanizeBytes(stats.TotalSize)) + } +} + func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, holdDID string) { // Calculate usage percentage if limit exists var usagePercent int @@ -102,31 +114,22 @@ func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, ho } } - // Check if user's tier earns a supporter badge on this hold - var hasSupporterBadge bool - if stats.Tier != "" && h.ReadOnlyDB != nil && holdDID != "" { - badge := db.GetSupporterBadge(h.ReadOnlyDB, stats.UserDID, holdDID) - hasSupporterBadge = badge != "" - } - data := struct { - UniqueBlobs int - TotalSize int64 - HumanSize string - HasLimit bool - HumanLimit string - UsagePercent int - Tier string - HasSupporterBadge bool + UniqueBlobs int + TotalSize int64 + HumanSize string + HasLimit bool + HumanLimit string + UsagePercent int + Tier string }{ - UniqueBlobs: stats.UniqueBlobs, - TotalSize: stats.TotalSize, - HumanSize: humanizeBytes(stats.TotalSize), - HasLimit: hasLimit, - HumanLimit: humanLimit, - UsagePercent: usagePercent, - Tier: stats.Tier, - HasSupporterBadge: hasSupporterBadge, + UniqueBlobs: stats.UniqueBlobs, + TotalSize: stats.TotalSize, + HumanSize: humanizeBytes(stats.TotalSize), + HasLimit: hasLimit, + HumanLimit: humanLimit, + UsagePercent: usagePercent, + Tier: stats.Tier, } w.Header().Set("Content-Type", "text/html") diff --git a/pkg/appview/handlers/subscription.go b/pkg/appview/handlers/subscription.go index ef0af9c..d0153f5 100644 --- a/pkg/appview/handlers/subscription.go +++ b/pkg/appview/handlers/subscription.go @@ -1,166 +1,38 @@ package handlers import ( - "bytes" - "encoding/json" - "fmt" "log/slog" "net/http" "atcr.io/pkg/appview/middleware" - "atcr.io/pkg/appview/storage" - "atcr.io/pkg/atproto" - "atcr.io/pkg/auth" + "atcr.io/pkg/billing" ) -// SubscriptionInfo mirrors the hold's billing.SubscriptionInfo for JSON decoding. -type SubscriptionInfo struct { - UserDID string `json:"userDid"` - CurrentTier string `json:"currentTier"` - CrewTier string `json:"crewTier,omitempty"` - CurrentUsage int64 `json:"currentUsage"` - CurrentLimit *int64 `json:"currentLimit,omitempty"` - PaymentsEnabled bool `json:"paymentsEnabled"` - Tiers []TierInfo `json:"tiers"` - SubscriptionID string `json:"subscriptionId,omitempty"` - BillingInterval string `json:"billingInterval,omitempty"` - Error string `json:"error,omitempty"` - HideBilling bool `json:"-"` // hide entire section (no billing support) - HoldDisplayName string `json:"-"` // human-readable hold name for display +// SubscriptionDisplay is the template-friendly subscription data. +type SubscriptionDisplay struct { + UserDID string + CurrentTier string + PaymentsEnabled bool + Tiers []TierDisplay + SubscriptionID string + BillingInterval string + HideBilling bool } -// TierInfo mirrors the hold's billing.TierInfo. -type TierInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - QuotaBytes int64 `json:"quotaBytes"` - QuotaFormatted string `json:"quotaFormatted"` - PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` - PriceCentsYearly int `json:"priceCentsYearly,omitempty"` - PriceFormatted string `json:"-"` // computed in handler, e.g., "$5/month" - IsCurrent bool `json:"isCurrent,omitempty"` +// TierDisplay is a template-friendly tier. +type TierDisplay struct { + ID string + Name string + Description string + Features []string + PriceCentsMonthly int + PriceCentsYearly int + PriceMonthly string // e.g. "$5/mo" + PriceYearly string // e.g. "$50/yr" + IsCurrent bool } -// SubscriptionHandler returns subscription info as HTML for HTMX. -type SubscriptionHandler struct { - BaseUIHandler -} - -func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r) - if user == nil { - h.renderHidden(w) - return - } - - // Use hold_did query param if provided (for previewing other holds), - // otherwise fall back to the user's saved default hold from their profile. - holdDID := r.URL.Query().Get("hold_did") - if holdDID == "" { - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - profile, err := storage.GetProfile(r.Context(), client) - if err != nil { - slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err) - h.renderHidden(w) - return - } - holdDID = h.DefaultHoldDID - if profile != nil && profile.DefaultHold != "" { - holdDID = profile.DefaultHold - } - } - - if holdDID == "" { - h.renderHidden(w) - return - } - - // Resolve hold DID to endpoint - holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) - if err != nil { - slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) - h.renderHidden(w) - return - } - - // Fetch subscription info from hold (public endpoint, no auth needed) - subURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getSubscriptionInfo?userDid=%s", holdEndpoint, user.DID) - resp, err := http.Get(subURL) - if err != nil { - slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err) - h.renderHidden(w) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - slog.Warn("Hold returned error for subscription", "status", resp.StatusCode) - h.renderHidden(w) - return - } - - var info SubscriptionInfo - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { - slog.Warn("Failed to decode subscription info", "error", err) - h.renderHidden(w) - return - } - - if !info.PaymentsEnabled { - h.renderHidden(w) - return - } - - // Set hold display name so users know which hold the subscription applies to - info.HoldDisplayName = resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, holdDID) - - // Format prices for display - // Note: -1 means "has price, fetch from Stripe" (placeholder from hold) - for i := range info.Tiers { - tier := &info.Tiers[i] - hasMonthly := tier.PriceCentsMonthly != 0 - hasYearly := tier.PriceCentsYearly != 0 - - switch { - case hasMonthly && tier.PriceCentsMonthly > 0: - tier.PriceFormatted = fmt.Sprintf("$%d/month", tier.PriceCentsMonthly/100) - case hasYearly && tier.PriceCentsYearly > 0: - tier.PriceFormatted = fmt.Sprintf("$%d/year", tier.PriceCentsYearly/100) - case hasMonthly || hasYearly: - // Has price but we don't know the amount (-1 sentinel) - tier.PriceFormatted = "Paid" - default: - tier.PriceFormatted = "Free" - } - } - - // Render the subscription info - h.renderInfo(w, info) -} - -func (h *SubscriptionHandler) renderInfo(w http.ResponseWriter, info SubscriptionInfo) { - w.Header().Set("Content-Type", "text/html") - if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil { - slog.Error("Failed to render subscription template", "error", err) - h.renderError(w, "Failed to render template") - } -} - -func (h *SubscriptionHandler) renderHidden(w http.ResponseWriter) { - w.Header().Set("Content-Type", "text/html") - info := SubscriptionInfo{HideBilling: true} - if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil { - slog.Error("Failed to render hidden subscription template", "error", err) - } -} - -func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) { - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, `
%s
`, message) -} - -// SubscriptionCheckoutHandler redirects to hold's Stripe checkout. +// SubscriptionCheckoutHandler redirects to Stripe checkout. type SubscriptionCheckoutHandler struct { BaseUIHandler } @@ -172,90 +44,36 @@ func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.R return } + if h.BillingManager == nil || !h.BillingManager.Enabled() { + http.Error(w, "Billing not available", http.StatusNotFound) + return + } + tier := r.URL.Query().Get("tier") if tier == "" { http.Error(w, "tier parameter required", http.StatusBadRequest) return } - // Get user's default hold - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - profile, err := storage.GetProfile(r.Context(), client) + interval := r.URL.Query().Get("interval") + if interval == "" { + interval = "monthly" + } + + resp, err := h.BillingManager.CreateCheckoutSession(r, user.DID, user.Handle, &billing.CheckoutSessionRequest{ + Tier: tier, + Interval: interval, + }) if err != nil { - http.Error(w, "Failed to load profile", http.StatusInternalServerError) - return - } - - holdDID := h.DefaultHoldDID - if profile != nil && profile.DefaultHold != "" { - holdDID = profile.DefaultHold - } - - if holdDID == "" { - http.Error(w, "No default hold configured", http.StatusBadRequest) - return - } - - // Resolve hold endpoint - holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) - if err != nil { - slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) - http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) - return - } - - // Get service token for the hold - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) - if err != nil { - slog.Warn("Failed to get service token for checkout", "did", user.DID, "holdDid", holdDID, "error", err) - http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError) - return - } - - // Call hold's checkout endpoint - checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint) - reqBody := map[string]string{ - "tier": tier, - "returnUrl": h.SiteURL + "/settings#storage", - } - bodyBytes, _ := json.Marshal(reqBody) - - req, err := http.NewRequestWithContext(r.Context(), "POST", checkoutURL, bytes.NewReader(bodyBytes)) - if err != nil { - http.Error(w, "Failed to create request", http.StatusInternalServerError) - return - } - req.Header.Set("Authorization", "Bearer "+serviceToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-User-DID", user.DID) - - httpClient := &http.Client{} - resp, err := httpClient.Do(req) - if err != nil { - slog.Warn("Failed to call checkout endpoint", "error", err) + slog.Warn("Failed to create checkout session", "did", user.DID, "tier", tier, "error", err) http.Error(w, "Failed to create checkout session", http.StatusInternalServerError) return } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - http.Error(w, "Hold returned error", resp.StatusCode) - return - } - - var checkoutResp struct { - CheckoutURL string `json:"checkoutUrl"` - } - if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil { - http.Error(w, "Invalid response from hold", http.StatusInternalServerError) - return - } - - // Redirect to Stripe checkout - http.Redirect(w, r, checkoutResp.CheckoutURL, http.StatusFound) + http.Redirect(w, r, resp.CheckoutURL, http.StatusFound) } -// SubscriptionPortalHandler redirects to hold's Stripe billing portal. +// SubscriptionPortalHandler redirects to Stripe billing portal. type SubscriptionPortalHandler struct { BaseUIHandler } @@ -267,73 +85,23 @@ func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Req return } - // Get user's default hold - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - profile, err := storage.GetProfile(r.Context(), client) - if err != nil { - http.Error(w, "Failed to load profile", http.StatusInternalServerError) + if h.BillingManager == nil || !h.BillingManager.Enabled() { + http.Error(w, "Billing not available", http.StatusNotFound) return } - holdDID := h.DefaultHoldDID - if profile != nil && profile.DefaultHold != "" { - holdDID = profile.DefaultHold + scheme := "https" + if r.TLS == nil { + scheme = "http" } + returnURL := scheme + "://" + h.SiteURL + "/settings#storage" - if holdDID == "" { - http.Error(w, "No default hold configured", http.StatusBadRequest) - return - } - - // Resolve hold endpoint - holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) + resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL) if err != nil { - slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) - http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) - return - } - - // Get service token - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) - if err != nil { - slog.Warn("Failed to get service token for portal", "did", user.DID, "holdDid", holdDID, "error", err) - http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError) - return - } - - // Call hold's portal endpoint - portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings%%23storage", holdEndpoint, h.SiteURL) - - req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil) - if err != nil { - http.Error(w, "Failed to create request", http.StatusInternalServerError) - return - } - req.Header.Set("Authorization", "Bearer "+serviceToken) - req.Header.Set("X-User-DID", user.DID) - - httpClient := &http.Client{} - resp, err := httpClient.Do(req) - if err != nil { - slog.Warn("Failed to call portal endpoint", "error", err) + slog.Warn("Failed to get billing portal URL", "did", user.DID, "error", err) http.Error(w, "Failed to get billing portal", http.StatusInternalServerError) return } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - http.Error(w, "Hold returned error", resp.StatusCode) - return - } - - var portalResp struct { - PortalURL string `json:"portalUrl"` - } - if err := json.NewDecoder(resp.Body).Decode(&portalResp); err != nil { - http.Error(w, "Invalid response from hold", http.StatusInternalServerError) - return - } - - // Redirect to Stripe portal - http.Redirect(w, r, portalResp.PortalURL, http.StatusFound) + http.Redirect(w, r, resp.PortalURL, http.StatusFound) } diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index bc77675..bfe59d4 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -62,18 +62,8 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } db.SetRegistryURL(cards, h.RegistryURL) - // Check for supporter badge on user's default hold - var supporterBadge string - if h.ReadOnlyDB != nil { - holdDID := viewedUser.DefaultHoldDID - if holdDID == "" { - // Fallback: check if user has any crew membership - holdDID = db.GetCrewHoldDID(h.ReadOnlyDB, viewedUser.DID) - } - if holdDID != "" { - supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, holdDID) - } - } + // Check for supporter badge based on billing subscription + supporterBadge := h.BillingManager.GetSupporterBadge(viewedUser.DID) // Build page meta meta := NewPageMeta( diff --git a/pkg/appview/handlers/webhooks.go b/pkg/appview/handlers/webhooks.go index 67a9201..afb95c3 100644 --- a/pkg/appview/handlers/webhooks.go +++ b/pkg/appview/handlers/webhooks.go @@ -1,42 +1,35 @@ package handlers import ( - "bytes" - "encoding/json" - "fmt" "log/slog" "net/http" + "strings" + "time" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" - "atcr.io/pkg/appview/storage" - "atcr.io/pkg/atproto" - "atcr.io/pkg/auth" + "atcr.io/pkg/appview/webhooks" "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) -// webhookListResponse mirrors the hold's listWebhooks response -type webhookListResponse struct { - Webhooks []webhookEntry `json:"webhooks"` - Limits webhookLimits `json:"limits"` -} - +// webhookEntry is the template data for displaying a webhook type webhookEntry struct { - Rkey string `json:"rkey"` - Triggers int `json:"triggers"` - URL string `json:"url"` - HasSecret bool `json:"hasSecret"` - CreatedAt string `json:"createdAt"` + ID string + Triggers int + URL string + HasSecret bool + CreatedAt string - // Computed fields (not from JSON) + // Computed fields from bitmask HasFirst bool HasAll bool HasChanged bool } type webhookLimits struct { - Max int `json:"max"` - AllTriggers bool `json:"allTriggers"` + Max int + AllTriggers bool } // WebhooksHandler returns the webhooks list partial via HTMX @@ -51,43 +44,17 @@ func (h *WebhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) + webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID) if err != nil { - h.renderWebhookError(w, "Could not resolve hold: "+err.Error()) + slog.Warn("Failed to list webhooks", "error", err) + h.renderWebhookError(w, "Failed to load webhooks") return } - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) - if err != nil { - h.renderWebhookError(w, "Failed to authenticate with hold") - return - } + // Get tier limits from billing manager + maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) - // Fetch webhooks from hold - listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID) - req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil) - req.Header.Set("Authorization", "Bearer "+serviceToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - slog.Warn("Failed to fetch webhooks from hold", "error", err) - h.renderWebhookError(w, "Hold unreachable") - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - h.renderWebhookError(w, fmt.Sprintf("Hold returned status %d", resp.StatusCode)) - return - } - - var listResp webhookListResponse - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - h.renderWebhookError(w, "Invalid response from hold") - return - } - - h.renderWebhookList(w, listResp, holdDID) + h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) } // AddWebhookHandler handles adding a new webhook via form POST @@ -109,84 +76,65 @@ func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Validate URL scheme + if !strings.HasPrefix(webhookURL, "https://") && !strings.HasPrefix(webhookURL, "http://") { + h.renderWebhookError(w, "Invalid webhook URL: must be https") + return + } + // Parse trigger checkboxes triggers := 0 if r.FormValue("trigger_first") == "on" { - triggers |= atproto.TriggerFirst + triggers |= webhooks.TriggerFirst } if r.FormValue("trigger_all") == "on" { - triggers |= atproto.TriggerAll + triggers |= webhooks.TriggerAll } if r.FormValue("trigger_changed") == "on" { - triggers |= atproto.TriggerChanged + triggers |= webhooks.TriggerChanged } if triggers == 0 { - triggers = atproto.TriggerFirst // default + triggers = webhooks.TriggerFirst // default } - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) + // Tier enforcement + maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) + + // Check webhook count limit + count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) if err != nil { - h.renderWebhookError(w, "Could not resolve hold") + h.renderWebhookError(w, "Failed to check webhook count") + return + } + if maxWebhooks >= 0 && count >= maxWebhooks { + h.renderWebhookError(w, "Webhook limit reached") return } - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) - if err != nil { - h.renderWebhookError(w, "Failed to authenticate with hold") + // Trigger bitmask enforcement: free users can only set TriggerFirst + if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 { + h.renderWebhookError(w, "Additional trigger types require a paid plan") return } - // Call hold addWebhook - addBody, _ := json.Marshal(map[string]any{ - "url": webhookURL, - "secret": secret, - "triggers": triggers, - }) + // Create webhook + webhook := &db.Webhook{ + ID: uuid.New().String(), + UserDID: user.DID, + URL: webhookURL, + Secret: secret, + Triggers: triggers, + CreatedAt: time.Now(), + } - addURL := holdEndpoint + atproto.HoldAddWebhook - req, _ := http.NewRequestWithContext(r.Context(), "POST", addURL, bytes.NewReader(addBody)) - req.Header.Set("Authorization", "Bearer "+serviceToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - h.renderWebhookError(w, "Hold unreachable") + if err := db.InsertWebhook(h.DB, webhook); err != nil { + slog.Warn("Failed to insert webhook", "error", err) + h.renderWebhookError(w, "Failed to add webhook") return } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - var errBody struct { - Message string `json:"message"` - } - body := make([]byte, 512) - n, _ := resp.Body.Read(body) - _ = json.Unmarshal(body[:n], &errBody) - msg := string(body[:n]) - if errBody.Message != "" { - msg = errBody.Message - } - h.renderWebhookError(w, "Failed to add webhook: "+msg) - return - } - - var addResp struct { - Rkey string `json:"rkey"` - CID string `json:"cid"` - } - _ = json.NewDecoder(resp.Body).Decode(&addResp) - - // Write sailor webhook record to user's PDS - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - sailorRecord := atproto.NewSailorWebhookRecord(holdDID, triggers, addResp.CID) - if _, err := client.PutRecord(r.Context(), atproto.SailorWebhookCollection, addResp.Rkey, sailorRecord); err != nil { - slog.Warn("Failed to write sailor webhook record to PDS (hold record exists)", - "did", user.DID, "rkey", addResp.Rkey, "error", err) - // Not fatal — hold has the record, PDS write is best-effort - } // Re-render the full list - h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken) + h.refetchAndRender(w, user) } // DeleteWebhookHandler handles deleting a webhook @@ -201,52 +149,23 @@ func (h *DeleteWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - rkey := chi.URLParam(r, "id") - if rkey == "" { + id := chi.URLParam(r, "id") + if id == "" { h.renderWebhookError(w, "Missing webhook ID") return } - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) - if err != nil { - h.renderWebhookError(w, "Could not resolve hold") + if err := db.DeleteWebhook(h.DB, id, user.DID); err != nil { + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not owned") { + h.renderWebhookError(w, "Webhook not found") + } else { + h.renderWebhookError(w, "Failed to delete webhook") + } return } - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) - if err != nil { - h.renderWebhookError(w, "Failed to authenticate with hold") - return - } - - // Call hold deleteWebhook - delBody, _ := json.Marshal(map[string]string{"rkey": rkey}) - delURL := holdEndpoint + atproto.HoldDeleteWebhook - req, _ := http.NewRequestWithContext(r.Context(), "POST", delURL, bytes.NewReader(delBody)) - req.Header.Set("Authorization", "Bearer "+serviceToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - h.renderWebhookError(w, "Hold unreachable") - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - h.renderWebhookError(w, "Failed to delete webhook") - return - } - - // Delete sailor webhook record from PDS (best-effort) - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - if err := client.DeleteRecord(r.Context(), atproto.SailorWebhookCollection, rkey); err != nil { - slog.Warn("Failed to delete sailor webhook record from PDS", - "did", user.DID, "rkey", rkey, "error", err) - } - // Re-render the full list - h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken) + h.refetchAndRender(w, user) } // TestWebhookHandler sends a test payload @@ -261,43 +180,24 @@ func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - rkey := chi.URLParam(r, "id") - if rkey == "" { + id := chi.URLParam(r, "id") + if id == "" { h.renderWebhookError(w, "Missing webhook ID") return } - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) - if err != nil { - h.renderWebhookError(w, "Could not resolve hold") + if h.WebhookDispatcher == nil { + h.renderAlert(w, "error", "Webhooks not configured") return } - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) + success, err := h.WebhookDispatcher.DeliverTest(r.Context(), id, user.DID, user.Handle) if err != nil { - h.renderWebhookError(w, "Failed to authenticate with hold") + h.renderAlert(w, "error", "Webhook not found or unauthorized") return } - testBody, _ := json.Marshal(map[string]string{"rkey": rkey}) - testURL := holdEndpoint + atproto.HoldTestWebhook - req, _ := http.NewRequestWithContext(r.Context(), "POST", testURL, bytes.NewReader(testBody)) - req.Header.Set("Authorization", "Bearer "+serviceToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - h.renderAlert(w, "error", "Hold unreachable") - return - } - defer resp.Body.Close() - - var testResp struct { - Success bool `json:"success"` - } - _ = json.NewDecoder(resp.Body).Decode(&testResp) - - if testResp.Success { + if success { h.renderAlert(w, "success", "Test webhook delivered successfully!") } else { h.renderAlert(w, "error", "Test delivery failed - check the webhook URL") @@ -306,70 +206,56 @@ func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // ---- Shared helpers ---- -func (h *BaseUIHandler) resolveUserHold(r *http.Request, user *db.User) (holdDID, holdEndpoint string, err error) { - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - profile, profileErr := storage.GetProfile(r.Context(), client) - - holdDID = h.DefaultHoldDID - if profileErr == nil && profile != nil && profile.DefaultHold != "" { - holdDID = profile.DefaultHold +// getWebhookLimits returns the webhook limits for a user based on their billing tier. +func (h *BaseUIHandler) getWebhookLimits(userDID string) (maxWebhooks int, allTriggers bool) { + if h.BillingManager != nil && h.BillingManager.Enabled() { + return h.BillingManager.GetWebhookLimits(userDID) } - - if holdDID == "" { - return "", "", fmt.Errorf("no hold configured") - } - - holdEndpoint, err = atproto.ResolveHoldURL(r.Context(), holdDID) - if err != nil { - return holdDID, "", fmt.Errorf("failed to resolve hold: %w", err) - } - return holdDID, holdEndpoint, nil + return 1, false } -func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, r *http.Request, user *db.User, holdDID, holdEndpoint, serviceToken string) { - listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID) - req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil) - req.Header.Set("Authorization", "Bearer "+serviceToken) - - resp, err := http.DefaultClient.Do(req) +func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) { + webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID) if err != nil { h.renderWebhookError(w, "Failed to refresh webhook list") return } - defer resp.Body.Close() - var listResp webhookListResponse - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - h.renderWebhookError(w, "Invalid response from hold") - return - } - - h.renderWebhookList(w, listResp, holdDID) + maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) + h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) } -func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, data webhookListResponse, holdDID string) { +func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) { w.Header().Set("Content-Type", "text/html") - // Populate computed trigger fields from bitmask - for i := range data.Webhooks { - data.Webhooks[i].HasFirst = data.Webhooks[i].Triggers&atproto.TriggerFirst != 0 - data.Webhooks[i].HasAll = data.Webhooks[i].Triggers&atproto.TriggerAll != 0 - data.Webhooks[i].HasChanged = data.Webhooks[i].Triggers&atproto.TriggerChanged != 0 + // Convert DB webhooks to template entries with computed trigger fields + entries := make([]webhookEntry, len(dbWebhooks)) + for i, wh := range dbWebhooks { + entries[i] = webhookEntry{ + ID: wh.ID, + Triggers: wh.Triggers, + URL: wh.URL, + HasSecret: wh.HasSecret, + CreatedAt: wh.CreatedAt.Format(time.RFC3339), + HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, + HasAll: wh.Triggers&webhooks.TriggerAll != 0, + HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, + } } templateData := struct { Webhooks []webhookEntry Limits webhookLimits - HoldDID string + ContainerID string TriggerInfo []triggerInfo }{ - Webhooks: data.Webhooks, - Limits: data.Limits, - HoldDID: holdDID, + Webhooks: entries, + Limits: limits, + ContainerID: "webhooks-content", TriggerInfo: []triggerInfo{ - {Name: "scan:first", Bit: atproto.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, - {Name: "scan:all", Bit: atproto.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, - {Name: "scan:changed", Bit: atproto.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, + {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, + {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, + {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, }, } diff --git a/pkg/appview/holdclient/tier_query.go b/pkg/appview/holdclient/tier_query.go new file mode 100644 index 0000000..880c480 --- /dev/null +++ b/pkg/appview/holdclient/tier_query.go @@ -0,0 +1,63 @@ +package holdclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "atcr.io/pkg/atproto" +) + +// HoldTierInfo describes a single tier from a hold's listTiers response. +type HoldTierInfo struct { + Name string `json:"name"` + QuotaBytes int64 `json:"quotaBytes"` + QuotaFormatted string `json:"quotaFormatted"` + ScanOnPush bool `json:"scanOnPush"` +} + +// HoldTiersResponse is the response from a hold's io.atcr.hold.listTiers endpoint. +type HoldTiersResponse struct { + Tiers []HoldTierInfo `json:"tiers"` +} + +// ListTiers queries a hold's public listTiers endpoint to get tier definitions. +// No authentication is required. +func ListTiers(ctx context.Context, holdDID string) (*HoldTiersResponse, error) { + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) + if err != nil { + return nil, fmt.Errorf("could not resolve hold DID to URL: %w", err) + } + + url := strings.TrimSuffix(holdURL, "/") + atproto.HoldListTiers + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to query listTiers on %s: %w", holdDID, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("listTiers on %s returned %d: %s", holdDID, resp.StatusCode, string(body)) + } + + var result HoldTiersResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode listTiers response from %s: %w", holdDID, err) + } + + return &result, nil +} diff --git a/pkg/appview/holdclient/tier_update.go b/pkg/appview/holdclient/tier_update.go new file mode 100644 index 0000000..d771c1b --- /dev/null +++ b/pkg/appview/holdclient/tier_update.go @@ -0,0 +1,97 @@ +// Package holdclient provides client functions for the appview to call hold XRPC endpoints. +package holdclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "atcr.io/pkg/atproto" + "atcr.io/pkg/auth" + "github.com/bluesky-social/indigo/atproto/atcrypto" +) + +// UpdateCrewTierOnHold calls io.atcr.hold.updateCrewTier on a specific hold. +// It signs a short-lived JWT with the appview's P-256 key and sends the tier update request. +func UpdateCrewTierOnHold(ctx context.Context, holdDID, holdURL, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) error { + // Sign appview service token + token, err := auth.CreateAppviewServiceToken(privateKey, appviewDID, holdDID, userDID) + if err != nil { + return fmt.Errorf("failed to create appview token: %w", err) + } + + // Build request body + body, err := json.Marshal(map[string]any{ + "userDid": userDID, + "tierRank": tierRank, + }) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Build URL + url := strings.TrimSuffix(holdURL, "/") + atproto.HoldUpdateCrewTier + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to call updateCrewTier on %s: %w", holdDID, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("updateCrewTier on %s returned %d: %s", holdDID, resp.StatusCode, string(respBody)) + } + + return nil +} + +// urlFromDIDWeb converts a did:web to its HTTPS URL. +func urlFromDIDWeb(did string) string { + if !strings.HasPrefix(did, "did:web:") { + return "" + } + host := strings.TrimPrefix(did, "did:web:") + host = strings.ReplaceAll(host, "%3A", ":") + return "https://" + host +} + +// UpdateCrewTierOnAllHolds pushes a tier update to all managed holds. +// It resolves each hold DID to a URL and calls updateCrewTier. +// Errors are logged but do not cause the function to fail — best effort. +func UpdateCrewTierOnAllHolds(ctx context.Context, managedHolds []string, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) { + for _, holdDID := range managedHolds { + holdURL := urlFromDIDWeb(holdDID) + if holdURL == "" { + slog.Warn("Could not resolve hold DID to URL, skipping", "holdDID", holdDID) + continue + } + + if err := UpdateCrewTierOnHold(ctx, holdDID, holdURL, userDID, tierRank, privateKey, appviewDID); err != nil { + slog.Error("Failed to update crew tier on hold", + "holdDID", holdDID, + "userDID", userDID, + "tierRank", tierRank, + "error", err, + ) + } else { + slog.Info("Updated crew tier on hold", + "holdDID", holdDID, + "userDID", userDID, + "tierRank", tierRank, + ) + } + } +} diff --git a/pkg/appview/jetstream/backfill.go b/pkg/appview/jetstream/backfill.go index 96a1839..a519eff 100644 --- a/pkg/appview/jetstream/backfill.go +++ b/pkg/appview/jetstream/backfill.go @@ -85,6 +85,7 @@ func (b *BackfillWorker) Start(ctx context.Context) error { atproto.StatsCollection, // io.atcr.hold.stats (from holds) atproto.CaptainCollection, // io.atcr.hold.captain (from holds) atproto.CrewCollection, // io.atcr.hold.crew (from holds) + atproto.ScanCollection, // io.atcr.hold.scan (from holds) } for _, collection := range collections { @@ -433,16 +434,6 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string) captainRecord.HoldDID = holdDID captainRecord.UpdatedAt = time.Now() - // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-" so unmarshal skips it) - var raw struct { - SupporterBadgeTiers []string `json:"supporterBadgeTiers"` - } - if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 { - if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil { - captainRecord.SupporterBadgeTiers = string(jsonBytes) - } - } - if err := db.UpsertCaptainRecord(b.db, &captainRecord); err != nil { return fmt.Errorf("failed to cache captain record: %w", err) } diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index f9ac03d..3023f55 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -10,6 +10,7 @@ import ( "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" + atpdata "github.com/bluesky-social/indigo/atproto/atdata" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/lexicon" ) @@ -17,11 +18,24 @@ import ( // Processor handles shared database operations for both Worker (live) and Backfill (sync) // This eliminates code duplication between the two data ingestion paths type Processor struct { - db db.DBTX - userCache *UserCache // Optional - enabled for Worker, disabled for Backfill - statsCache *StatsCache // In-memory cache for per-hold stats aggregation - useCache bool - catalog *lexicon.ResolvingCatalog // For debug logging of validation failures + db db.DBTX + userCache *UserCache // Optional - enabled for Worker, disabled for Backfill + statsCache *StatsCache // In-memory cache for per-hold stats aggregation + useCache bool + catalog *lexicon.ResolvingCatalog // For debug logging of validation failures + webhookDispatcher WebhookDispatcher // Optional - only for live Worker (nil for Backfill) +} + +// WebhookDispatcher is an interface for dispatching webhooks on scan completion. +// Only the live Worker sets this; backfill does NOT (avoids spamming old scan results). +type WebhookDispatcher interface { + DispatchForScan(ctx context.Context, scan, previousScan *db.Scan, userHandle, tag, holdEndpoint string) +} + +// SetWebhookDispatcher sets the webhook dispatcher for scan processing. +// Only the live Worker should set this — backfill skips webhook dispatch. +func (p *Processor) SetWebhookDispatcher(d WebhookDispatcher) { + p.webhookDispatcher = d } // NewProcessor creates a new shared processor @@ -107,12 +121,60 @@ func (p *Processor) EnsureUser(ctx context.Context, did string) error { return db.UpsertUserIgnoreAvatar(p.db, user) } +// EnsureUserExists ensures a user row exists in the database without updating it. +// Used by non-profile collections to avoid unnecessary writes during backfill. +// If the user doesn't exist, resolves identity and inserts with ON CONFLICT DO NOTHING. +func (p *Processor) EnsureUserExists(ctx context.Context, did string) error { + // Check cache first (if enabled) + if p.useCache && p.userCache != nil { + if _, ok := p.userCache.cache[did]; ok { + return nil // User in cache, nothing to do + } + } else if !p.useCache { + // No cache - check if user already exists in DB + existingUser, err := db.GetUserByDID(p.db, did) + if err == nil && existingUser != nil { + return nil // User exists, nothing to do + } + } + + // User doesn't exist yet — resolve and insert + resolvedDID, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did) + if err != nil { + return err + } + + avatarURL := "" + client := atproto.NewClient(pdsEndpoint, "", "") + profileRecord, err := client.GetProfileRecord(ctx, resolvedDID) + if err != nil { + slog.Warn("Failed to fetch profile record", "component", "processor", "did", resolvedDID, "error", err) + } else if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { + avatarURL = atproto.BlobCDNURL(resolvedDID, profileRecord.Avatar.Ref.Link) + } + + user := &db.User{ + DID: resolvedDID, + Handle: handle, + PDSEndpoint: pdsEndpoint, + Avatar: avatarURL, + LastSeen: time.Now(), + } + + // Cache if enabled + if p.useCache { + p.userCache.cache[did] = user + } + + return db.InsertUserIfNotExists(p.db, user) +} + // ValidateRecord performs validation on records. // - Full lexicon validation is logged for debugging but does NOT block ingestion // - Targeted validation (captain/crew DID checks) DOES block bogus records func (p *Processor) ValidateRecord(ctx context.Context, collection string, data []byte) error { - var recordData map[string]any - if err := json.Unmarshal(data, &recordData); err != nil { + recordData, err := atpdata.UnmarshalJSON(data) + if err != nil { return fmt.Errorf("invalid JSON: %w", err) } @@ -171,13 +233,18 @@ func (p *Processor) ProcessRecord(ctx context.Context, did, collection, rkey str // Skip for deletes - user should already exist, and we don't need to resolve identity if !isDelete { switch collection { + case atproto.SailorProfileCollection: + // Sailor profile is the authoritative source for user data — full upsert + if err := p.EnsureUser(ctx, did); err != nil { + return fmt.Errorf("failed to ensure user: %w", err) + } case atproto.ManifestCollection, atproto.TagCollection, atproto.StarCollection, - atproto.RepoPageCollection, - atproto.SailorProfileCollection: - if err := p.EnsureUser(ctx, did); err != nil { - return fmt.Errorf("failed to ensure user: %w", err) + atproto.RepoPageCollection: + // Other user collections just need the row to exist — no update if unchanged + if err := p.EnsureUserExists(ctx, did); err != nil { + return fmt.Errorf("failed to ensure user exists: %w", err) } // Hold collections (captain, crew, stats) - don't create user entries // These are records FROM holds, not user activity @@ -216,6 +283,9 @@ func (p *Processor) ProcessRecord(ctx context.Context, did, collection, rkey str case atproto.SailorProfileCollection: return p.ProcessSailorProfile(ctx, did, data, queryCaptainFn) + case atproto.ScanCollection: + return p.ProcessScan(ctx, did, data, isDelete) + case atproto.StatsCollection: return p.ProcessStats(ctx, did, data, isDelete) @@ -424,7 +494,7 @@ func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []by // Ensure the starred repository's owner exists in the users table // (the starrer is already ensured by ProcessRecord, but the owner // may not have been processed yet during backfill or live events) - if err := p.EnsureUser(ctx, ownerDID); err != nil { + if err := p.EnsureUserExists(ctx, ownerDID); err != nil { return fmt.Errorf("failed to ensure star subject user: %w", err) } @@ -615,6 +685,78 @@ func (p *Processor) RefreshUserAvatar(ctx context.Context, did, pdsEndpoint stri return nil } +// ProcessScan handles scan record events from hold PDSes. +// Caches scan results in the appview DB and dispatches webhooks (if dispatcher is set). +func (p *Processor) ProcessScan(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error { + if isDelete { + return nil // Scan deletes are not processed (scans are immutable) + } + + // Unmarshal scan record + var scanRecord atproto.ScanRecord + if err := json.Unmarshal(recordData, &scanRecord); err != nil { + return fmt.Errorf("failed to unmarshal scan record: %w", err) + } + + // Extract manifest digest from the scan record's manifest AT-URI + manifestDigest := "" + if parts := strings.Split(scanRecord.Manifest, "/"); len(parts) > 0 { + manifestDigest = "sha256:" + parts[len(parts)-1] + } + + // Parse scanned_at timestamp + scannedAt := time.Now() + if t, err := time.Parse(time.RFC3339, scanRecord.ScannedAt); err == nil { + scannedAt = t + } + + scan := &db.Scan{ + HoldDID: holdDID, + ManifestDigest: manifestDigest, + UserDID: scanRecord.UserDID, + Repository: scanRecord.Repository, + Critical: int(scanRecord.Critical), + High: int(scanRecord.High), + Medium: int(scanRecord.Medium), + Low: int(scanRecord.Low), + Total: int(scanRecord.Total), + ScannerVersion: scanRecord.ScannerVersion, + ScannedAt: scannedAt, + } + + // Upsert scan to DB (returns previous scan for change detection) + previousScan, err := db.UpsertScan(p.db, scan) + if err != nil { + return fmt.Errorf("failed to upsert scan: %w", err) + } + + // Dispatch webhooks if dispatcher is set (live Worker only, not backfill) + if p.webhookDispatcher != nil { + // Resolve user handle from cache or DB + userHandle := "" + user, userErr := db.GetUserByDID(p.db, scanRecord.UserDID) + if userErr == nil && user != nil { + userHandle = user.Handle + } + + // Resolve tag for the manifest digest + tag := "" + if tagVal, tagErr := db.GetTagByDigest(p.db, scanRecord.UserDID, scanRecord.Repository, manifestDigest); tagErr == nil { + tag = tagVal + } + + // Resolve hold endpoint URL + holdEndpoint := "" + if holdURL, holdErr := atproto.ResolveHoldURL(ctx, holdDID); holdErr == nil { + holdEndpoint = holdURL + } + + p.webhookDispatcher.DispatchForScan(ctx, scan, previousScan, userHandle, tag, holdEndpoint) + } + + return nil +} + // ProcessStats handles stats record events from hold PDSes // This is called when Jetstream receives a stats create/update/delete event from a hold // The holdDID is the DID of the hold PDS (event.DID), and the record contains ownerDID + repository @@ -679,25 +821,16 @@ func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordDa return fmt.Errorf("failed to unmarshal captain record: %w", err) } - // Marshal supporter badge tiers to JSON string for storage - badgeTiersJSON := "" - if len(captainRecord.SupporterBadgeTiers) > 0 { - if jsonBytes, err := json.Marshal(captainRecord.SupporterBadgeTiers); err == nil { - badgeTiersJSON = string(jsonBytes) - } - } - // Convert to db struct and upsert record := &db.HoldCaptainRecord{ - HoldDID: holdDID, - OwnerDID: captainRecord.Owner, - Public: captainRecord.Public, - AllowAllCrew: captainRecord.AllowAllCrew, - DeployedAt: captainRecord.DeployedAt, - Region: captainRecord.Region, - Successor: captainRecord.Successor, - SupporterBadgeTiers: badgeTiersJSON, - UpdatedAt: time.Now(), + HoldDID: holdDID, + OwnerDID: captainRecord.Owner, + Public: captainRecord.Public, + AllowAllCrew: captainRecord.AllowAllCrew, + DeployedAt: captainRecord.DeployedAt, + Region: captainRecord.Region, + Successor: captainRecord.Successor, + UpdatedAt: time.Now(), } if err := db.UpsertCaptainRecord(p.db, record); err != nil { @@ -747,13 +880,6 @@ func (p *Processor) ProcessCrew(ctx context.Context, holdDID string, rkey string return fmt.Errorf("failed to upsert crew member: %w", err) } - slog.Debug("Processed crew record", - "component", "processor", - "hold_did", holdDID, - "member_did", crewRecord.Member, - "role", crewRecord.Role, - "permissions", crewRecord.Permissions) - return nil } diff --git a/pkg/appview/jetstream/worker.go b/pkg/appview/jetstream/worker.go index 2598512..f7e852a 100644 --- a/pkg/appview/jetstream/worker.go +++ b/pkg/appview/jetstream/worker.go @@ -353,6 +353,11 @@ func (w *Worker) SetEventCallback(cb EventCallback) { w.eventCallback = cb } +// Processor returns the worker's processor for configuration (e.g., setting webhook dispatcher) +func (w *Worker) Processor() *Processor { + return w.processor +} + // GetLastCursor returns the last processed cursor (time_us) for reconnects func (w *Worker) GetLastCursor() int64 { w.cursorMutex.RLock() @@ -482,6 +487,7 @@ func isRelevantCollection(collection string) bool { atproto.StatsCollection, atproto.CaptainCollection, atproto.CrewCollection, + atproto.ScanCollection, BlueskyProfileCollection: // For avatar sync return true default: diff --git a/pkg/appview/public/icons.svg b/pkg/appview/public/icons.svg index 6a41ffd..965a522 100644 --- a/pkg/appview/public/icons.svg +++ b/pkg/appview/public/icons.svg @@ -6,7 +6,6 @@ - @@ -19,6 +18,7 @@ + diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 6f56da5..51001ea 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -11,7 +11,9 @@ import ( "atcr.io/pkg/appview/holdhealth" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/appview/readme" + "atcr.io/pkg/appview/webhooks" "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/billing" indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/go-chi/chi/v5" ) @@ -24,22 +26,24 @@ type LegalConfig struct { // UIDependencies contains all dependencies needed for UI route registration type UIDependencies struct { - Database *sql.DB - ReadOnlyDB *sql.DB - SessionStore *db.SessionStore - OAuthClientApp *indigooauth.ClientApp - OAuthStore *db.OAuthStore - Refresher *oauth.Refresher - BaseURL string - RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL - DeviceStore *db.DeviceStore - HealthChecker *holdhealth.Checker - ReadmeFetcher *readme.Fetcher - Templates *template.Template - DefaultHoldDID string - LegalConfig LegalConfig - ClientName string // Full name: "AT Container Registry" - ClientShortName string // Short name: "ATCR" + Database *sql.DB + ReadOnlyDB *sql.DB + SessionStore *db.SessionStore + OAuthClientApp *indigooauth.ClientApp + OAuthStore *db.OAuthStore + Refresher *oauth.Refresher + BaseURL string + RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL + DeviceStore *db.DeviceStore + HealthChecker *holdhealth.Checker + ReadmeFetcher *readme.Fetcher + Templates *template.Template + DefaultHoldDID string + LegalConfig LegalConfig + ClientName string // Full name: "AT Container Registry" + ClientShortName string // Short name: "ATCR" + BillingManager *billing.Manager // Stripe billing manager (nil if not configured) + WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured) } // RegisterUIRoutes registers all web UI and API routes on the provided router @@ -55,23 +59,25 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { // Create base with all dependencies - handlers just embed this base := uihandlers.BaseUIHandler{ - Templates: deps.Templates, - RegistryURL: registryURL, - SiteURL: siteURL, - DB: deps.Database, - ReadOnlyDB: deps.ReadOnlyDB, - Refresher: deps.Refresher, - HealthChecker: deps.HealthChecker, - ReadmeFetcher: deps.ReadmeFetcher, - Directory: deps.OAuthClientApp.Dir, - SessionStore: deps.SessionStore, - DeviceStore: deps.DeviceStore, - OAuthStore: deps.OAuthStore, - DefaultHoldDID: deps.DefaultHoldDID, - CompanyName: deps.LegalConfig.CompanyName, - Jurisdiction: deps.LegalConfig.Jurisdiction, - ClientName: deps.ClientName, - ClientShortName: deps.ClientShortName, + Templates: deps.Templates, + RegistryURL: registryURL, + SiteURL: siteURL, + DB: deps.Database, + ReadOnlyDB: deps.ReadOnlyDB, + Refresher: deps.Refresher, + HealthChecker: deps.HealthChecker, + ReadmeFetcher: deps.ReadmeFetcher, + Directory: deps.OAuthClientApp.Dir, + SessionStore: deps.SessionStore, + DeviceStore: deps.DeviceStore, + OAuthStore: deps.OAuthStore, + BillingManager: deps.BillingManager, + WebhookDispatcher: deps.WebhookDispatcher, + DefaultHoldDID: deps.DefaultHoldDID, + CompanyName: deps.LegalConfig.CompanyName, + Jurisdiction: deps.LegalConfig.Jurisdiction, + ClientName: deps.ClientName, + ClientShortName: deps.ClientShortName, } // OAuth login routes (public) @@ -154,7 +160,6 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) // Subscription management - r.Get("/api/subscription", (&uihandlers.SubscriptionHandler{BaseUIHandler: base}).ServeHTTP) r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP) r.Get("/settings/subscription/portal", (&uihandlers.SubscriptionPortalHandler{BaseUIHandler: base}).ServeHTTP) diff --git a/pkg/appview/server.go b/pkg/appview/server.go index b25af40..54eafba 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -28,12 +28,15 @@ import ( "atcr.io/pkg/appview/readme" "atcr.io/pkg/appview/routes" "atcr.io/pkg/appview/storage" + "atcr.io/pkg/appview/webhooks" "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/oauth" "atcr.io/pkg/auth/token" + "atcr.io/pkg/billing" "atcr.io/pkg/logging" + "github.com/bluesky-social/indigo/atproto/atcrypto" indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" ) @@ -95,6 +98,15 @@ type AppViewServer struct { // HoldAuthorizer checks hold access permissions. HoldAuthorizer auth.HoldAuthorizer + // OAuthKey is the P-256 private key used for OAuth client auth and appview service identity. + OAuthKey *atcrypto.PrivateKeyP256 + + // BillingManager handles Stripe billing and tier updates (nil if billing disabled). + BillingManager *billing.Manager + + // WebhookDispatcher dispatches scan webhooks (stored in appview DB). + WebhookDispatcher *webhooks.Dispatcher + // Private fields for lifecycle management oauthHooks []OAuthPostAuthHook tokenHooks []TokenPostAuthHook @@ -191,6 +203,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, if err != nil { return nil, fmt.Errorf("failed to load OAuth key: %w", err) } + s.OAuthKey = oauthKey // Create OAuth client app desiredScopes := oauth.GetDefaultScopes(defaultHoldDID) @@ -236,6 +249,39 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, }() } + // Initialize billing manager + appviewDID := DIDFromBaseURL(baseURL) + s.BillingManager = billing.New( + &cfg.Billing, + oauthKey, + appviewDID, + cfg.Server.ManagedHolds, + baseURL, + ) + // Allow hold captains to bypass billing feature gates + if len(cfg.Server.ManagedHolds) > 0 { + managedHolds := cfg.Server.ManagedHolds + roDB := s.ReadOnlyDB + s.BillingManager.SetCaptainChecker(func(userDID string) bool { + isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds) + return isCaptain + }) + } + if s.BillingManager.Enabled() { + slog.Info("Billing enabled", "appview_did", appviewDID, "managed_holds", len(cfg.Server.ManagedHolds)) + go s.BillingManager.RefreshHoldTiers() + } + + // Create webhook dispatcher + appviewMeta := atproto.AppviewMetadata{ + ClientName: cfg.Server.ClientName, + ClientShortName: cfg.Server.ClientShortName, + BaseURL: cfg.Server.BaseURL, + FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png", + RegistryDomains: cfg.Server.RegistryDomains, + } + s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta) + // Initialize Jetstream workers s.initializeJetstream() @@ -264,27 +310,32 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, // Register UI routes routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{ - Database: s.Database, - ReadOnlyDB: s.ReadOnlyDB, - SessionStore: s.SessionStore, - OAuthClientApp: s.OAuthClientApp, - OAuthStore: s.OAuthStore, - Refresher: s.Refresher, - BaseURL: baseURL, - RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains), - DeviceStore: s.DeviceStore, - HealthChecker: s.HealthChecker, - ReadmeFetcher: s.ReadmeFetcher, - Templates: s.Templates, - DefaultHoldDID: defaultHoldDID, - ClientName: cfg.Server.ClientName, - ClientShortName: cfg.Server.ClientShortName, + Database: s.Database, + ReadOnlyDB: s.ReadOnlyDB, + SessionStore: s.SessionStore, + OAuthClientApp: s.OAuthClientApp, + OAuthStore: s.OAuthStore, + Refresher: s.Refresher, + BaseURL: baseURL, + RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains), + DeviceStore: s.DeviceStore, + HealthChecker: s.HealthChecker, + ReadmeFetcher: s.ReadmeFetcher, + Templates: s.Templates, + DefaultHoldDID: defaultHoldDID, + ClientName: cfg.Server.ClientName, + ClientShortName: cfg.Server.ClientShortName, + BillingManager: s.BillingManager, + WebhookDispatcher: s.WebhookDispatcher, LegalConfig: routes.LegalConfig{ CompanyName: cfg.Legal.CompanyName, Jurisdiction: cfg.Legal.Jurisdiction, }, }) + // Register Stripe webhook route (if billing enabled) + s.BillingManager.RegisterRoutes(mainRouter) + // Create OAuth server s.OAuthServer = oauth.NewServer(s.OAuthClientApp) s.OAuthServer.SetRefresher(s.Refresher) @@ -550,6 +601,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, } }) + // Appview DID document endpoint (service identity for key discovery) + mainRouter.Get("/.well-known/did.json", s.handleDIDDocument) + // Register credential helper version API (public endpoint) routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo) @@ -692,12 +746,99 @@ func primaryRegistryDomain(domains []string) string { return "" } +// DID returns the appview's did:web identity derived from its BaseURL. +func (s *AppViewServer) DID() string { + return DIDFromBaseURL(s.Config.Server.BaseURL) +} + +// DIDFromBaseURL derives a did:web identifier from a base URL. +// Per the did:web spec, non-standard ports are percent-encoded. +// Examples: +// +// "https://atcr.io" → "did:web:atcr.io" +// "http://localhost:5000" → "did:web:localhost%3A5000" +func DIDFromBaseURL(baseURL string) string { + u, err := url.Parse(baseURL) + if err != nil { + return "did:web:localhost" + } + + hostname := u.Hostname() + if hostname == "" { + hostname = "localhost" + } + + port := u.Port() + isStandardPort := (u.Scheme == "https" && port == "443") || + (u.Scheme == "http" && port == "80") || + port == "" + + if isStandardPort { + return "did:web:" + hostname + } + return fmt.Sprintf("did:web:%s%%3A%s", hostname, port) +} + +// handleDIDDocument serves the appview's DID document at /.well-known/did.json. +// This is a service identity for key discovery — no PDS, no repo, no firehose. +// Holds use this to discover the appview's P-256 public key for JWT verification. +func (s *AppViewServer) handleDIDDocument(w http.ResponseWriter, r *http.Request) { + did := s.DID() + + pubKey, err := s.OAuthKey.PublicKey() + if err != nil { + slog.Error("Failed to get public key for DID document", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + doc := map[string]any{ + "@context": []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + }, + "id": did, + "verificationMethod": []map[string]any{ + { + "id": did + "#appview", + "type": "Multikey", + "controller": did, + "publicKeyMultibase": pubKey.Multibase(), + }, + }, + "authentication": []string{ + did + "#appview", + }, + "assertionMethod": []string{ + did + "#appview", + }, + "service": []map[string]any{ + { + "id": "#atcr_appview", + "type": "AtcrAppView", + "serviceEndpoint": s.Config.Server.BaseURL, + }, + }, + } + + w.Header().Set("Content-Type", "application/did+ld+json") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Access-Control-Allow-Origin", "*") + if err := json.NewEncoder(w).Encode(doc); err != nil { + slog.Error("Failed to encode DID document", "error", err) + } +} + // initializeJetstream initializes the Jetstream workers for real-time events and backfill. func (s *AppViewServer) initializeJetstream() { jetstreamURLs := s.Config.Jetstream.URLs go func() { worker := jetstream.NewWorker(s.Database, jetstreamURLs, 0) + // Set webhook dispatcher on live worker (backfill skips dispatch) + if s.WebhookDispatcher != nil { + worker.Processor().SetWebhookDispatcher(s.WebhookDispatcher) + } worker.StartWithFailover(context.Background()) }() slog.Info("Jetstream real-time worker started", "component", "jetstream", "endpoints", len(jetstreamURLs)) @@ -724,22 +865,25 @@ func (s *AppViewServer) initializeJetstream() { } }() - interval := 1 * time.Hour + interval := s.Config.Jetstream.BackfillInterval + if interval > 0 { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() - go func() { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for range ticker.C { - slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval) - if err := backfillWorker.Start(context.Background()); err != nil { - slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err) - } else { - slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill") + for range ticker.C { + slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval) + if err := backfillWorker.Start(context.Background()); err != nil { + slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err) + } else { + slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill") + } } - } - }() - slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval) + }() + slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval) + } else { + slog.Info("Periodic backfill disabled (interval=0), only startup backfill will run", "component", "jetstream/backfill") + } } } } diff --git a/pkg/appview/src/js/app.js b/pkg/appview/src/js/app.js index 2087acf..8491bda 100644 --- a/pkg/appview/src/js/app.js +++ b/pkg/appview/src/js/app.js @@ -734,9 +734,9 @@ function showToast(message, type) { } // Test webhook via fetch + toast -async function testWebhook(rkey) { +async function testWebhook(id) { try { - const resp = await fetch(`/api/webhooks/${rkey}/test`, { + const resp = await fetch(`/api/webhooks/${id}/test`, { method: 'POST', credentials: 'include', }); diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index af7e9b8..3f6a4da 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -9,20 +9,22 @@ {{ template "nav" . }}
-

Settings

+ + +
- - @@ -35,36 +37,49 @@
- -
-
-

Identity

-
-
- Handle - {{ .Profile.Handle }} -
-
- DID - {{ .Profile.DID }} -
-
- PDS - {{ .Profile.PDSEndpoint }} + +
+ + {{ template "subscription_plans" .Subscription }} + + + {{ if .AllHolds }} +
+
+ {{ template "hold_selector" . }} + {{ if .ActiveHold }} + {{ template "hold_card" .ActiveHold }} + {{ else }} +
+ No active hold selected. Choose one above.
+ {{ end }}
-
+
+ {{ if .OtherHolds }} + {{ template "other_holds_table" .OtherHolds }} + {{ end }} +
+
+ {{ else }} +
+ No holds configured. Push an image to get started. +
+ {{ end }}
@@ -127,117 +142,15 @@
- - - @@ -302,16 +215,12 @@
-