A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

billing refactor, move billing to appview, move webhooks to appview

evan.jarrett.net 136c0a0e dc31ca2f

verified
+4044 -4279
+1 -1
.air.toml
··· 7 7 poll_interval = 500 8 8 # Pre-build: generate assets if missing (each string is a shell command) 9 9 pre_cmd = ["go generate ./pkg/appview/..."] 10 - cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview" 10 + cmd = "go build -tags billing -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview" 11 11 entrypoint = ["./tmp/atcr-appview", "serve", "--config", "config-appview.example.yaml"] 12 12 include_ext = ["go", "html", "css", "js"] 13 13 exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules", "pkg/hold"]
+1 -1
cmd/hold/repo.go
··· 131 131 return nil, nil, fmt.Errorf("failed to open hold database: %w", err) 132 132 } 133 133 134 - holdPDS, err := pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, false, holdDB.DB) 134 + holdPDS, err := pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, false, holdDB.DB) 135 135 if err != nil { 136 136 holdDB.Close() 137 137 return nil, nil, fmt.Errorf("failed to initialize PDS: %w", err)
+88 -25
cmd/relay-compare/main.go
··· 41 41 atproto.TagCollection, // io.atcr.tag 42 42 atproto.SailorProfileCollection, // io.atcr.sailor.profile 43 43 atproto.StarCollection, // io.atcr.sailor.star 44 - atproto.SailorWebhookCollection, // io.atcr.sailor.webhook 45 44 atproto.RepoPageCollection, // io.atcr.repo.page 46 45 atproto.CaptainCollection, // io.atcr.hold.captain 47 46 atproto.CrewCollection, // io.atcr.hold.crew 48 47 atproto.LayerCollection, // io.atcr.hold.layer 49 48 atproto.StatsCollection, // io.atcr.hold.stats 50 49 atproto.ScanCollection, // io.atcr.hold.scan 51 - atproto.WebhookCollection, // io.atcr.hold.webhook 52 50 } 53 51 54 52 type summaryRow struct { 55 - collection string 56 - counts []int 57 - status string // "sync", "diff", "error" 58 - diffCount int 59 - realGaps int // verified: record exists on PDS but relay is missing it 60 - ghosts int // verified: record doesn't exist on PDS, relay has stale entry 53 + collection string 54 + counts []int 55 + status string // "sync", "diff", "error" 56 + diffCount int 57 + realGaps int // verified: record exists on PDS but relay is missing it 58 + ghosts int // verified: record doesn't exist on PDS, relay has stale entry 59 + deactivated int // verified: account deactivated/deleted on PDS 61 60 } 62 61 63 62 // verifyResult holds the PDS verification result for a (DID, collection) pair. 64 63 type verifyResult struct { 65 - exists bool 66 - err error 64 + exists bool 65 + deactivated bool // account deactivated/deleted on PDS 66 + err error 67 67 } 68 68 69 69 // key identifies a (collection, relay-or-DID) pair for result lookups. ··· 211 211 totalMissing := 0 212 212 totalRealGaps := 0 213 213 totalGhosts := 0 214 + totalDeactivated := 0 214 215 215 216 for _, col := range cols { 216 217 fmt.Printf("\n%s%s━━━ %s ━━━%s\n", cBold, cCyan, col, cReset) ··· 258 259 suffix = fmt.Sprintf(" %s(verify: unknown)%s", cDim, cReset) 259 260 } else if vr.err != nil { 260 261 suffix = fmt.Sprintf(" %s(verify: %s)%s", cDim, vr.err, cReset) 262 + } else if vr.deactivated { 263 + suffix = fmt.Sprintf(" %s← deactivated%s", cDim, cReset) 264 + row.deactivated++ 265 + totalDeactivated++ 261 266 } else if vr.exists { 262 267 suffix = fmt.Sprintf(" %s← real gap%s", cRed, cReset) 263 268 row.realGaps++ ··· 272 277 } 273 278 } 274 279 280 + // When verifying, ghost/deactivated-only diffs are considered in sync 281 + if !inSync && *verify && row.realGaps == 0 { 282 + inSync = true 283 + } 284 + 275 285 if inSync { 276 - fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset) 286 + notes := formatSyncNotes(row.ghosts, row.deactivated) 287 + if notes != "" { 288 + fmt.Printf(" %s✓ in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset) 289 + } else { 290 + fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset) 291 + } 277 292 row.status = "sync" 278 293 } else { 279 294 row.status = "diff" ··· 282 297 } 283 298 284 299 // Summary table 285 - printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts) 300 + printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts, totalDeactivated) 286 301 } 287 302 288 - func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts int) { 303 + func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts, totalDeactivated int) { 289 304 fmt.Printf("\n%s%s━━━ Summary ━━━%s\n\n", cBold, cCyan, cReset) 290 305 291 306 colW := 28 ··· 321 336 } 322 337 switch row.status { 323 338 case "sync": 324 - fmt.Printf(" %s✓ in sync%s", cGreen, cReset) 339 + notes := formatSyncNotes(row.ghosts, row.deactivated) 340 + if notes != "" { 341 + fmt.Printf(" %s✓ in sync%s %s(%s)%s", cGreen, cReset, cDim, notes, cReset) 342 + } else { 343 + fmt.Printf(" %s✓ in sync%s", cGreen, cReset) 344 + } 325 345 case "diff": 326 346 if showVerify { 327 - fmt.Printf(" %s≠ %d missing%s %s(%d real, %d ghost)%s", 328 - cYellow, row.diffCount, cReset, cDim, row.realGaps, row.ghosts, cReset) 347 + notes := formatSyncNotes(row.ghosts, row.deactivated) 348 + if notes != "" { 349 + notes = ", " + notes 350 + } 351 + fmt.Printf(" %s≠ %d missing%s %s(%s)%s", 352 + cYellow, row.realGaps, cReset, cDim, fmt.Sprintf("%d real%s", row.realGaps, notes), cReset) 329 353 } else { 330 354 fmt.Printf(" %s≠ %d missing%s", cYellow, row.diffCount, cReset) 331 355 } ··· 338 362 // Footer 339 363 fmt.Println() 340 364 if totalMissing > 0 { 341 - fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset) 342 - if showVerify { 343 - fmt.Printf(" %s%d real gaps%s (record exists on PDS), %s%d ghosts%s (record deleted from PDS)\n", 344 - cRed, totalRealGaps, cReset, cDim, totalGhosts, cReset) 365 + if showVerify && totalRealGaps == 0 { 366 + notes := formatSyncNotes(totalGhosts, totalDeactivated) 367 + fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset) 368 + } else { 369 + if showVerify { 370 + fmt.Printf("%s%d real gaps across relays%s", cYellow, totalRealGaps, cReset) 371 + notes := formatSyncNotes(totalGhosts, totalDeactivated) 372 + if notes != "" { 373 + fmt.Printf(" %s(%s)%s", cDim, notes, cReset) 374 + } 375 + fmt.Println() 376 + } else { 377 + fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset) 378 + } 345 379 } 346 380 } else { 347 381 fmt.Printf("%s✓ All relays fully in sync%s\n", cGreen, cReset) 348 382 } 349 383 } 350 384 385 + // formatSyncNotes builds a parenthetical like "2 ghost, 1 deactivated" for sync status. 386 + // Returns empty string if both counts are zero. 387 + func formatSyncNotes(ghosts, deactivated int) string { 388 + var parts []string 389 + if ghosts > 0 { 390 + parts = append(parts, fmt.Sprintf("%d ghost", ghosts)) 391 + } 392 + if deactivated > 0 { 393 + parts = append(parts, fmt.Sprintf("%d deactivated", deactivated)) 394 + } 395 + return strings.Join(parts, ", ") 396 + } 397 + 351 398 // verifyDiffs resolves each diff DID to its PDS and checks if records actually exist. 352 399 func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { 353 400 // Collect unique (DID, collection) pairs to verify ··· 402 449 403 450 k := key{dc.col, dc.did} 404 451 405 - // Check if DID resolution failed 452 + // Check if DID resolution failed — could mean account is deactivated/tombstoned 406 453 if err, ok := pdsErrors[dc.did]; ok { 407 - mu.Lock() 408 - results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)} 409 - mu.Unlock() 454 + errStr := err.Error() 455 + if strings.Contains(errStr, "no PDS endpoint") || 456 + strings.Contains(errStr, "not found") { 457 + mu.Lock() 458 + results[k] = verifyResult{deactivated: true} 459 + mu.Unlock() 460 + } else { 461 + mu.Lock() 462 + results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)} 463 + mu.Unlock() 464 + } 410 465 return 411 466 } 412 467 ··· 415 470 records, _, err := client.ListRecordsForRepo(ctx, dc.did, dc.col, 1, "") 416 471 mu.Lock() 417 472 if err != nil { 418 - results[k] = verifyResult{err: err} 473 + errStr := err.Error() 474 + if strings.Contains(errStr, "Could not find repo") || 475 + strings.Contains(errStr, "RepoDeactivated") || 476 + strings.Contains(errStr, "RepoTakendown") || 477 + strings.Contains(errStr, "RepoSuspended") { 478 + results[k] = verifyResult{deactivated: true} 479 + } else { 480 + results[k] = verifyResult{err: err} 481 + } 419 482 } else { 420 483 results[k] = verifyResult{exists: len(records) > 0} 421 484 }
+78
config-appview.example.yaml
··· 37 37 client_short_name: ATCR 38 38 # Separate domains for OCI registry API (e.g. ["buoy.cr"]). First is primary. Browser visits redirect to BaseURL. 39 39 registry_domains: [] 40 + # DIDs of holds this appview manages billing for. Tier updates are pushed to these holds. 41 + managed_holds: 42 + - did:web:172.28.0.3%3A8080 40 43 # Web UI settings. 41 44 ui: 42 45 # SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals. ··· 65 68 - wss://jetstream1.us-east.bsky.network/subscribe 66 69 # Sync existing records from PDS on startup. 67 70 backfill_enabled: true 71 + # How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup. 72 + backfill_interval: 24h0m0s 68 73 # Relay endpoints for backfill, tried in order on failure. 69 74 relay_endpoints: 70 75 - https://relay1.us-east.bsky.network ··· 85 90 company_name: "" 86 91 # Governing law jurisdiction for legal terms. 87 92 jurisdiction: "" 93 + # Stripe billing integration (requires -tags billing build). 94 + billing: 95 + # Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set. 96 + stripe_secret_key: "" 97 + # Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence). 98 + webhook_secret: "" 99 + # ISO 4217 currency code (e.g. "usd"). 100 + currency: usd 101 + # Redirect URL after successful checkout. Use {base_url} placeholder. 102 + success_url: '{base_url}/settings#storage' 103 + # Redirect URL after cancelled checkout. Use {base_url} placeholder. 104 + cancel_url: '{base_url}/settings#storage' 105 + # Subscription tiers ordered by rank (lowest to highest). 106 + tiers: 107 + - # Tier name. Position in list determines rank (0-based). 108 + name: free 109 + # Short description shown on the plan card. 110 + description: Get started with basic storage 111 + # List of features included in this tier. 112 + features: [] 113 + # Stripe price ID for monthly billing. Empty = free tier. 114 + stripe_price_monthly: "" 115 + # Stripe price ID for yearly billing. 116 + stripe_price_yearly: "" 117 + # Maximum webhooks for this tier (-1 = unlimited). 118 + max_webhooks: 1 119 + # Allow all webhook trigger types (not just first-scan). 120 + webhook_all_triggers: false 121 + supporter_badge: false 122 + - # Tier name. Position in list determines rank (0-based). 123 + name: deckhand 124 + # Short description shown on the plan card. 125 + description: Get started with basic storage 126 + # List of features included in this tier. 127 + features: [] 128 + # Stripe price ID for monthly billing. Empty = free tier. 129 + stripe_price_monthly: "" 130 + # Stripe price ID for yearly billing. 131 + stripe_price_yearly: "" 132 + # Maximum webhooks for this tier (-1 = unlimited). 133 + max_webhooks: 1 134 + # Allow all webhook trigger types (not just first-scan). 135 + webhook_all_triggers: false 136 + supporter_badge: true 137 + - # Tier name. Position in list determines rank (0-based). 138 + name: bosun 139 + # Short description shown on the plan card. 140 + description: More storage with scan-on-push 141 + # List of features included in this tier. 142 + features: [] 143 + # Stripe price ID for monthly billing. Empty = free tier. 144 + stripe_price_monthly: "" 145 + # Stripe price ID for yearly billing. 146 + stripe_price_yearly: "" 147 + # Maximum webhooks for this tier (-1 = unlimited). 148 + max_webhooks: 10 149 + # Allow all webhook trigger types (not just first-scan). 150 + webhook_all_triggers: true 151 + supporter_badge: true 152 + # - # Tier name. Position in list determines rank (0-based). 153 + # name: quartermaster 154 + # # Short description shown on the plan card. 155 + # description: Maximum storage for power users 156 + # # List of features included in this tier. 157 + # features: [] 158 + # # Stripe price ID for monthly billing. Empty = free tier. 159 + # stripe_price_monthly: price_xxx 160 + # # Stripe price ID for yearly billing. 161 + # stripe_price_yearly: price_yyy 162 + # # Maximum webhooks for this tier (-1 = unlimited). 163 + # max_webhooks: -1 164 + # # Allow all webhook trigger types (not just first-scan). 165 + # webhook_all_triggers: true
+8 -22
config-hold.example.yaml
··· 47 47 test_mode: false 48 48 # Request crawl from this relay on startup to make the embedded PDS discoverable. 49 49 relay_endpoint: "" 50 - # Preferred appview URL for links in webhooks and Bluesky posts, e.g. "https://seamark.dev". 51 - appview_url: https://atcr.io 50 + # DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key. 51 + appview_did: did:web:172.28.0.2%3A5000 52 52 # Read timeout for HTTP requests. 53 53 read_timeout: 5m0s 54 54 # Write timeout for HTTP requests. ··· 102 102 # Quota tiers ordered by rank (lowest to highest). Position determines rank. 103 103 tiers: 104 104 - # Tier name used as the key for crew assignments. 105 + name: free 106 + # Storage quota limit (e.g. "5GB", "50GB", "1TB"). 107 + quota: 5GB 108 + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. 109 + scan_on_push: false 110 + - # Tier name used as the key for crew assignments. 105 111 name: deckhand 106 112 # Storage quota limit (e.g. "5GB", "50GB", "1TB"). 107 113 quota: 5GB 108 114 # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. 109 115 scan_on_push: false 110 - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. 111 - max_webhooks: 1 112 - # Allow all webhook trigger types. Free tiers only get scan:first. 113 - webhook_all_triggers: false 114 - # Show supporter badge on user profiles for members at this tier. 115 - supporter_badge: false 116 116 - # Tier name used as the key for crew assignments. 117 117 name: bosun 118 118 # Storage quota limit (e.g. "5GB", "50GB", "1TB"). 119 119 quota: 50GB 120 120 # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. 121 121 scan_on_push: true 122 - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. 123 - max_webhooks: 5 124 - # Allow all webhook trigger types. Free tiers only get scan:first. 125 - webhook_all_triggers: true 126 - # Show supporter badge on user profiles for members at this tier. 127 - supporter_badge: true 128 122 - # Tier name used as the key for crew assignments. 129 123 name: quartermaster 130 124 # Storage quota limit (e.g. "5GB", "50GB", "1TB"). 131 125 quota: 100GB 132 126 # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. 133 127 scan_on_push: true 134 - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. 135 - max_webhooks: -1 136 - # Allow all webhook trigger types. Free tiers only get scan:first. 137 - webhook_all_triggers: true 138 - # Show supporter badge on user profiles for members at this tier. 139 - supporter_badge: true 140 128 # Default tier assignment for new crew members. 141 129 defaults: 142 130 # Tier assigned to new crew members who don't have an explicit tier. 143 131 new_crew_tier: deckhand 144 - # Show supporter badge on the hold owner's profile. 145 - owner_badge: true 146 132 # Vulnerability scanner settings. Empty disables scanning. 147 133 scanner: 148 134 # Shared secret for scanner WebSocket auth. Empty disables scanning.
+1
deploy/upcloud/configs/appview.yaml.tmpl
··· 34 34 - wss://jetstream2.us-east.bsky.network/subscribe 35 35 - wss://jetstream1.us-east.bsky.network/subscribe 36 36 backfill_enabled: true 37 + backfill_interval: 24h 37 38 relay_endpoints: 38 39 - https://relay1.us-east.bsky.network 39 40 - https://relay1.us-west.bsky.network
+1 -9
deploy/upcloud/configs/hold.yaml.tmpl
··· 21 21 successor: "" 22 22 test_mode: false 23 23 relay_endpoint: "" 24 - appview_url: https://seamark.dev 24 + appview_did: did:web:seamark.dev 25 25 read_timeout: 5m0s 26 26 write_timeout: 5m0s 27 27 registration: ··· 50 50 tiers: 51 51 - name: deckhand 52 52 quota: 5GB 53 - max_webhooks: 1 54 53 - name: bosun 55 54 quota: 50GB 56 55 scan_on_push: true 57 - max_webhooks: 5 58 - webhook_all_triggers: true 59 - supporter_badge: true 60 56 - name: quartermaster 61 57 quota: 100GB 62 58 scan_on_push: true 63 - max_webhooks: -1 64 - webhook_all_triggers: true 65 - supporter_badge: true 66 59 defaults: 67 60 new_crew_tier: deckhand 68 - owner_badge: true 69 61 scanner: 70 62 secret: "{{.ScannerSecret}}" 71 63 rescan_interval: 168h0m0s
+4 -4
docker-compose.yml
··· 20 20 ATCR_LOG_LEVEL: debug 21 21 LOG_SHIPPER_BACKEND: victoria 22 22 LOG_SHIPPER_URL: http://172.28.0.10:9428 23 + # Stripe billing (only used with -tags billing) 24 + STRIPE_SECRET_KEY: sk_test_ 25 + STRIPE_PUBLISHABLE_KEY: pk_test_ 26 + STRIPE_WEBHOOK_SECRET: whsec_ 23 27 # Limit local Docker logs - real logs go to Victoria Logs 24 28 # Local logs just for live tailing (docker logs -f) 25 29 logging: ··· 57 61 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg 58 62 HOLD_REGISTRATION_ALLOW_ALL_CREW: true 59 63 HOLD_SERVER_TEST_MODE: true 60 - # Stripe billing (only used with -tags billing) 61 - STRIPE_SECRET_KEY: sk_test_ 62 - STRIPE_PUBLISHABLE_KEY: pk_test_ 63 - STRIPE_WEBHOOK_SECRET: whsec_ 64 64 HOLD_LOG_LEVEL: debug 65 65 LOG_SHIPPER_BACKEND: victoria 66 66 LOG_SHIPPER_URL: http://172.28.0.10:9428
+348
docs/BILLING_REFACTOR.md
··· 1 + # Billing & Webhooks Refactor: Move to AppView 2 + 3 + ## Motivation 4 + 5 + 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: 6 + 7 + 1. **Multi-hold confusion**: A user on 3 holds could have 3 separate Stripe subscriptions with no unified view 8 + 2. **Orphaned subscriptions**: Users can end up paying for holds they no longer use after switching their active hold 9 + 3. **Complex UI**: The settings page needs to surface billing per-hold, with separate "Manage Billing" links for each 10 + 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 11 + 12 + The proposed model is **per-appview**: a single Stripe integration on the appview, one subscription per user, covering all holds that appview manages. 13 + 14 + ## Current Architecture 15 + 16 + ``` 17 + User ──Settings UI──→ AppView ──XRPC──→ Hold ──Stripe API──→ Stripe 18 + 19 + Stripe Webhooks 20 + ``` 21 + 22 + ### What lives where today 23 + 24 + | Component | Location | Notes | 25 + |-----------|----------|-------| 26 + | Stripe customer management | Hold (`pkg/hold/billing/`) | Build tag: `-tags billing` | 27 + | Stripe checkout/portal | Hold XRPC endpoints | Authenticated via service token | 28 + | Stripe webhook receiver | Hold (`stripeWebhook` endpoint) | Updates crew tier on subscription change | 29 + | Tier definitions + pricing | Hold config (`quotas.yaml`, `billing` section) | Captain configures | 30 + | Quota enforcement | Hold (`pkg/hold/quota/`) | Checks tier limit on push | 31 + | Storage quota calculation | Hold PDS layer records | Deduped per-user | 32 + | Subscription UI | AppView handlers | Proxies all calls to hold | 33 + | Webhook management (scan) | Hold PDS + SQLite | URL/secret in SQLite, metadata in PDS record | 34 + | Webhook dispatch | Hold (`scan_broadcaster.go`) | Sends on scan completion | 35 + | Sailor webhook record | User's PDS | Links to hold's private webhook record | 36 + 37 + ## Proposed Architecture 38 + 39 + ``` 40 + User ──Settings UI──→ AppView ──Stripe API──→ Stripe 41 + │ ↑ 42 + │ Stripe Webhooks 43 + 44 + ├──XRPC──→ Hold A (quota enforcement, scan results) 45 + ├──XRPC──→ Hold B 46 + └──XRPC──→ Hold C 47 + 48 + AppView signs attestation 49 + 50 + └──→ Hold stores in PDS (trust anchor) 51 + ``` 52 + 53 + ### What moves to AppView 54 + 55 + | Component | From | To | Notes | 56 + |-----------|------|----|-------| 57 + | Stripe customer management | Hold | AppView | One customer per user, not per hold | 58 + | Stripe checkout/portal | Hold | AppView | Single subscription covers all holds | 59 + | Stripe webhook receiver | Hold | AppView | AppView updates tier across all holds | 60 + | Tier definitions + pricing | Hold config | AppView config | AppView defines billing tiers | 61 + | Scan webhooks (storage + dispatch) | Hold | AppView | AppView has user context, scan data comes via Jetstream/XRPC | 62 + 63 + ### What stays on the hold 64 + 65 + | Component | Notes | 66 + |-----------|-------| 67 + | Quota enforcement | Hold still checks tier limit on push | 68 + | Storage quota calculation | Layer records stay in hold PDS | 69 + | Tier definitions (quota only) | Hold defines storage limits per tier, no pricing | 70 + | Scan execution + results | Scanner still talks to hold, results stored in hold PDS | 71 + | Crew tier field | Source of truth for enforcement, updated by appview | 72 + 73 + ## Billing Model 74 + 75 + ### One subscription, all holds 76 + 77 + A user pays the appview once. Their subscription tier applies across every hold the appview manages. 78 + 79 + ``` 80 + AppView billing tiers: [Free] [Tier 1] [Tier 2] 81 + │ │ │ 82 + ▼ ▼ ▼ 83 + Hold A tiers (3GB/10GB/50GB): deckhand bosun quartermaster 84 + Hold B tiers (5GB/20GB/∞): deckhand bosun quartermaster 85 + ``` 86 + 87 + ### Tier pairing 88 + 89 + 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. 90 + 91 + - AppView doesn't need to know tier names — just "slot 1, slot 2, slot 3" 92 + - Each hold independently decides what storage limit each tier gets 93 + - The settings UI shows the range: "5-10 GB depending on region" or "minimum 5 GB" 94 + 95 + ### Hold captains who want to charge 96 + 97 + 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. 98 + 99 + ## AppView-Hold Trust Model 100 + 101 + ### Problem 102 + 103 + 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. 104 + 105 + ### Attestation handshake 106 + 107 + 1. **Hold config** already has `server.appview_url` (preferred appview) 108 + 2. **AppView config** gains a `managed_holds` list (DIDs of holds it manages) 109 + 3. On first connection, the appview signs an attestation with its private key: 110 + ```json 111 + { 112 + "$type": "io.atcr.appview.attestation", 113 + "appviewDid": "did:web:atcr.io", 114 + "holdDid": "did:web:hold01.atcr.io", 115 + "issuedAt": "2026-02-23T...", 116 + "signature": "<signed with appview's P-256 key>" 117 + } 118 + ``` 119 + 4. The hold stores this attestation in its embedded PDS 120 + 5. On subsequent requests, the hold can challenge the appview: present the attestation, appview proves it holds the matching private key 121 + 6. If the appview's domain changes, the attestation (tied to DID, not URL) remains valid 122 + 123 + ### Trust verification flow 124 + 125 + ``` 126 + AppView boots → checks managed_holds list 127 + → for each hold: 128 + → calls hold's describeServer endpoint to verify DID 129 + → signs attestation { appviewDid, holdDid, issuedAt } 130 + → sends to hold via XRPC 131 + → hold stores in PDS as io.atcr.hold.appview record 132 + 133 + Hold receives tier update from appview: 134 + → checks: does this request come from my preferred appview? 135 + → verifies: signature on stored attestation matches appview's current key 136 + → if valid: updates crew tier 137 + → if invalid: rejects, logs warning 138 + ``` 139 + 140 + ### Key material 141 + 142 + - **AppView**: P-256 key (already exists at `/var/lib/atcr/oauth/client.key`, used for OAuth) 143 + - **Hold**: K-256 key (PDS signing key) 144 + - Attestation is signed by appview's P-256 key, verifiable by anyone with the appview's public key (available via DID document) 145 + 146 + ## Webhooks: Move to AppView 147 + 148 + ### Why move 149 + 150 + Scan webhooks currently live on the hold, but: 151 + - The webhook payload needs user handles, repository names, tags — all resolved by the appview 152 + - The hold only has DIDs and digests 153 + - The appview already processes scan records via Jetstream (backfill + live) 154 + - Webhook secrets shouldn't need to live on every hold the user pushes to 155 + 156 + ### New flow 157 + 158 + ``` 159 + Scanner completes scan 160 + → Hold stores scan record in PDS 161 + → Jetstream delivers scan record to AppView 162 + → AppView resolves user handle, repo name, tags 163 + → AppView dispatches webhooks with full context 164 + ``` 165 + 166 + ### What changes 167 + 168 + | Aspect | Current (hold) | Proposed (appview) | 169 + |--------|---------------|-------------------| 170 + | Webhook storage | Hold SQLite + PDS record | AppView DB + user's PDS record | 171 + | Webhook secrets | Hold SQLite (`webhook_secrets` table) | AppView DB | 172 + | Dispatch trigger | `scan_broadcaster.go` on scan completion | Jetstream processor on `io.atcr.hold.scan` record | 173 + | Payload enrichment | Hold fetches handle from appview metadata | AppView has full context natively | 174 + | Discord/Slack formatting | Hold (`webhooks.go`) | AppView (same code, moved) | 175 + | Tier-based limits | Hold quota manager | AppView billing tier | 176 + | XRPC endpoints | Hold (`listWebhooks`, `addWebhook`, etc.) | AppView API endpoints (already exist as proxies) | 177 + 178 + ### Webhook record changes 179 + 180 + 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. 181 + 182 + The `io.atcr.hold.webhook` record in the hold's PDS is no longer needed. Webhooks are appview-scoped, not hold-scoped. 183 + 184 + ### Migration path 185 + 186 + 1. AppView gains webhook storage in its own DB (new table) 187 + 2. AppView gains webhook dispatch in its Jetstream processor 188 + 3. Hold's webhook endpoints deprecated (return 410 Gone after transition period) 189 + 4. Existing hold webhook records migrated via one-time script reading from hold XRPC + user PDS 190 + 191 + ## Config Changes 192 + 193 + ### AppView config additions 194 + 195 + ```yaml 196 + server: 197 + # Existing 198 + default_hold_did: "did:web:hold01.atcr.io" 199 + 200 + # New 201 + managed_holds: 202 + - "did:web:hold01.atcr.io" 203 + - "did:plc:abc123..." 204 + 205 + # New section 206 + billing: 207 + enabled: true 208 + currency: usd 209 + success_url: "{base_url}/settings#storage" 210 + cancel_url: "{base_url}/settings#storage" 211 + tiers: 212 + - name: "Free" 213 + # No stripe_price = free tier 214 + - name: "Standard" 215 + stripe_price_monthly: price_xxx 216 + stripe_price_yearly: price_yyy 217 + - name: "Pro" 218 + stripe_price_monthly: price_xxx 219 + stripe_price_yearly: price_yyy 220 + ``` 221 + 222 + ### AppView environment additions 223 + 224 + ```bash 225 + STRIPE_SECRET_KEY=sk_live_xxx 226 + STRIPE_WEBHOOK_SECRET=whsec_xxx 227 + ``` 228 + 229 + ### Hold config changes 230 + 231 + ```yaml 232 + # Removed 233 + billing: 234 + # entire section removed from hold config 235 + 236 + # Stays (quota enforcement only) 237 + quota: 238 + tiers: 239 + - name: deckhand 240 + quota: 5GB 241 + - name: bosun 242 + quota: 50GB 243 + - name: quartermaster 244 + quota: 100GB 245 + defaults: 246 + new_crew_tier: deckhand 247 + ``` 248 + 249 + The hold no longer has Stripe config. It just defines storage limits per tier and enforces them. 250 + 251 + ## AppView DB Schema Additions 252 + 253 + ```sql 254 + -- Webhook configurations (moved from hold SQLite) 255 + CREATE TABLE webhooks ( 256 + id INTEGER PRIMARY KEY AUTOINCREMENT, 257 + user_did TEXT NOT NULL, 258 + url TEXT NOT NULL, 259 + secret_hash TEXT, -- bcrypt hash of HMAC secret 260 + triggers INTEGER NOT NULL DEFAULT 1, -- bitmask: first=1, all=2, changed=4 261 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 262 + UNIQUE(user_did, url) 263 + ); 264 + 265 + -- Billing: track which holds have been attested 266 + CREATE TABLE hold_attestations ( 267 + hold_did TEXT PRIMARY KEY, 268 + attestation_cid TEXT NOT NULL, -- CID of attestation record in hold's PDS 269 + issued_at DATETIME NOT NULL, 270 + verified_at DATETIME 271 + ); 272 + ``` 273 + 274 + 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. 275 + 276 + ## Implementation Phases 277 + 278 + ### Phase 1: Trust foundation 279 + - Add `managed_holds` to appview config 280 + - Implement attestation signing (appview) and storage (hold) 281 + - Add attestation verification to hold's tier-update endpoint 282 + - New XRPC endpoint on hold: `io.atcr.hold.updateCrewTier` (appview-authenticated) 283 + 284 + ### Phase 2: Billing migration 285 + - Move Stripe integration from hold to appview (reuse `pkg/hold/billing/` code) 286 + - AppView billing uses `-tags billing` build tag (same pattern) 287 + - Implement tier pairing: appview billing slots mapped to hold tier lists 288 + - New appview endpoints: checkout, portal, stripe webhook receiver 289 + - Settings UI: single subscription section (not per-hold) 290 + 291 + ### Phase 3: Webhook migration ✅ 292 + - Add webhook + scans tables to appview DB 293 + - Implement webhook dispatch in appview's Jetstream processor 294 + - Move Discord/Slack formatting code to `pkg/appview/webhooks/` 295 + - Deprecate hold webhook XRPC endpoints (X-Deprecated header) 296 + - Webhooks now user-scoped (global across all holds) in appview DB 297 + - Scan records cached from Jetstream for change detection 298 + 299 + ### Phase 4: Cleanup ✅ 300 + - Removed hold webhook XRPC endpoints, dispatch code, and `webhooks.go` 301 + - Removed `io.atcr.hold.webhook` and `io.atcr.sailor.webhook` record types + lexicons 302 + - Removed `webhook_secrets` SQLite schema from scan_broadcaster 303 + - Removed `MaxWebhooks`/`WebhookAllTriggers` from hold quota config 304 + - Removed sailor webhook from OAuth scopes 305 + 306 + ## Settings UI Impact 307 + 308 + The storage tab simplifies significantly: 309 + 310 + ``` 311 + ┌──────────────────────────────────────────────────────┐ 312 + │ Active Hold: [▼ hold01.atcr.io (Crew) ] │ 313 + └──────────────────────────────────────────────────────┘ 314 + 315 + ┌──────────────────────────────────────────────────────┐ 316 + │ Subscription: Standard ($5/mo) [Manage Billing] │ 317 + │ Storage: 3-5 GB depending on region │ 318 + └──────────────────────────────────────────────────────┘ 319 + 320 + ┌──────────────────────────────────────────────────────┐ 321 + │ ★ hold01.atcr.io [Active] [Crew] [Online] │ 322 + │ Tier: bosun · 281.5 MB / 5.0 GB (5%) │ 323 + │ ▸ Webhooks (2 configured) │ 324 + └──────────────────────────────────────────────────────┘ 325 + 326 + ┌──────────────────────────────────────────────────────┐ 327 + │ Other Holds Role Status Storage │ 328 + │ hold02.atcr.io Crew ● 230 MB / 3 GB │ 329 + │ hold03.atcr.io Owner ● No data │ 330 + └──────────────────────────────────────────────────────┘ 331 + ``` 332 + 333 + Key changes: 334 + - **One subscription section** at the top (not per-hold) 335 + - **Webhooks section** under active hold card (managed by appview now) 336 + - **No "Paid" badge per hold** — subscription is global 337 + - **Storage range** shown on subscription card ("3-5 GB depending on region") 338 + - **Per-hold quota** still shown (each hold enforces its own limit for the user's tier) 339 + 340 + ## Open Questions 341 + 342 + 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`. 343 + 344 + 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. 345 + 346 + 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. 347 + 348 + 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).
+8 -8
docs/HOLD_XRPC_ENDPOINTS.md
··· 21 21 | `/xrpc/com.atproto.identity.resolveHandle` | GET | Resolve handle to DID | 22 22 | `/xrpc/app.bsky.actor.getProfile` | GET | Get actor profile | 23 23 | `/xrpc/app.bsky.actor.getProfiles` | GET | Get multiple profiles | 24 + | `/xrpc/io.atcr.hold.listTiers` | GET | List hold's available tiers with quotas and features | 24 25 | `/.well-known/did.json` | GET | DID document | 25 26 | `/.well-known/atproto-did` | GET | DID for handle resolution | 26 27 ··· 43 44 |----------|--------|-------------| 44 45 | `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership | 45 46 | `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) | 46 - | `/xrpc/io.atcr.hold.listWebhooks` | GET | List user's webhook configurations | 47 - | `/xrpc/io.atcr.hold.addWebhook` | POST | Add a webhook (tier-gated) | 48 - | `/xrpc/io.atcr.hold.deleteWebhook` | POST | Delete a webhook | 49 - | `/xrpc/io.atcr.hold.testWebhook` | POST | Send test payload to a webhook | 47 + ### Appview Token Required 48 + 49 + | Endpoint | Method | Description | 50 + |----------|--------|-------------| 51 + | `/xrpc/io.atcr.hold.updateCrewTier` | POST | Update a crew member's tier (appview-only) | 50 52 51 53 --- 52 54 ··· 78 80 | `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership | 79 81 | `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export | 80 82 | `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info | 81 - | `/xrpc/io.atcr.hold.listWebhooks` | GET | auth | List user's webhook configs | 82 - | `/xrpc/io.atcr.hold.addWebhook` | POST | auth | Add webhook (tier-gated) | 83 - | `/xrpc/io.atcr.hold.deleteWebhook` | POST | auth | Delete a webhook | 84 - | `/xrpc/io.atcr.hold.testWebhook` | POST | auth | Send test payload to webhook | 83 + | `/xrpc/io.atcr.hold.listTiers` | GET | none | List hold's available tiers with quotas and features (scanOnPush) | 84 + | `/xrpc/io.atcr.hold.updateCrewTier` | POST | appview token | Update crew member's tier | 85 85 86 86 --- 87 87
-59
lexicons/io/atcr/hold/addWebhook.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "io.atcr.hold.addWebhook", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "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.", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["url", "triggers"], 13 - "properties": { 14 - "url": { 15 - "type": "string", 16 - "format": "uri", 17 - "maxLength": 2048, 18 - "description": "HTTPS URL to receive webhook payloads" 19 - }, 20 - "secret": { 21 - "type": "string", 22 - "description": "Optional HMAC-SHA256 signing secret. When set, payloads include an X-Webhook-Signature-256 header.", 23 - "maxLength": 256 24 - }, 25 - "triggers": { 26 - "type": "integer", 27 - "minimum": 1, 28 - "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" 29 - } 30 - } 31 - } 32 - }, 33 - "output": { 34 - "encoding": "application/json", 35 - "schema": { 36 - "type": "object", 37 - "required": ["rkey", "cid"], 38 - "properties": { 39 - "rkey": { 40 - "type": "string", 41 - "maxLength": 64, 42 - "description": "Record key of the created io.atcr.hold.webhook record" 43 - }, 44 - "cid": { 45 - "type": "string", 46 - "maxLength": 128, 47 - "description": "CID of the created record (used as privateCid in the sailor webhook record)" 48 - } 49 - } 50 - } 51 - }, 52 - "errors": [ 53 - { "name": "InvalidUrl", "description": "URL is not a valid HTTPS endpoint" }, 54 - { "name": "WebhookLimitReached", "description": "User has reached the maximum number of webhooks for their tier" }, 55 - { "name": "TriggerNotAllowed", "description": "Trigger types beyond scan:first require a paid tier" } 56 - ] 57 - } 58 - } 59 - }
-8
lexicons/io/atcr/hold/captain.json
··· 41 41 "type": "string", 42 42 "format": "did", 43 43 "description": "DID of successor hold for migration redirect" 44 - }, 45 - "supporterBadgeTiers": { 46 - "type": "array", 47 - "description": "Tier names that earn a supporter badge on user profiles", 48 - "items": { 49 - "type": "string", 50 - "maxLength": 64 51 - } 52 44 } 53 45 } 54 46 }
-41
lexicons/io/atcr/hold/deleteWebhook.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "io.atcr.hold.deleteWebhook", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "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.", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["rkey"], 13 - "properties": { 14 - "rkey": { 15 - "type": "string", 16 - "maxLength": 64, 17 - "description": "Record key of the io.atcr.hold.webhook record to delete" 18 - } 19 - } 20 - } 21 - }, 22 - "output": { 23 - "encoding": "application/json", 24 - "schema": { 25 - "type": "object", 26 - "required": ["success"], 27 - "properties": { 28 - "success": { 29 - "type": "boolean", 30 - "description": "Whether the webhook was successfully deleted" 31 - } 32 - } 33 - } 34 - }, 35 - "errors": [ 36 - { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" }, 37 - { "name": "Unauthorized", "description": "Webhook belongs to a different user" } 38 - ] 39 - } 40 - } 41 - }
+50
lexicons/io/atcr/hold/listTiers.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.listTiers", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List the hold's available tiers with storage quotas (no pricing info).", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["tiers"], 13 + "properties": { 14 + "tiers": { 15 + "type": "array", 16 + "items": { 17 + "type": "ref", 18 + "ref": "#defs/tierInfo" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }, 25 + "tierInfo": { 26 + "type": "object", 27 + "required": ["name", "quotaBytes", "quotaFormatted", "scanOnPush"], 28 + "properties": { 29 + "name": { 30 + "type": "string", 31 + "maxLength": 64, 32 + "description": "Tier name." 33 + }, 34 + "quotaBytes": { 35 + "type": "integer", 36 + "description": "Storage quota in bytes." 37 + }, 38 + "quotaFormatted": { 39 + "type": "string", 40 + "maxLength": 32, 41 + "description": "Human-readable quota (e.g. '5.0 GB')." 42 + }, 43 + "scanOnPush": { 44 + "type": "boolean", 45 + "description": "Whether pushing triggers an immediate vulnerability scan." 46 + } 47 + } 48 + } 49 + } 50 + }
-86
lexicons/io/atcr/hold/listWebhooks.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "io.atcr.hold.listWebhooks", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "List webhook configurations for a user. Returns masked URLs (never full URLs), trigger settings, and tier-based limits. Requires service token authentication.", 8 - "parameters": { 9 - "type": "params", 10 - "required": ["userDid"], 11 - "properties": { 12 - "userDid": { 13 - "type": "string", 14 - "format": "did", 15 - "description": "DID of the user to list webhooks for" 16 - } 17 - } 18 - }, 19 - "output": { 20 - "encoding": "application/json", 21 - "schema": { 22 - "type": "object", 23 - "required": ["webhooks", "limits"], 24 - "properties": { 25 - "webhooks": { 26 - "type": "array", 27 - "description": "List of configured webhooks", 28 - "items": { 29 - "type": "ref", 30 - "ref": "#webhookEntry" 31 - } 32 - }, 33 - "limits": { 34 - "type": "ref", 35 - "ref": "#webhookLimits" 36 - } 37 - } 38 - } 39 - } 40 - }, 41 - "webhookEntry": { 42 - "type": "object", 43 - "required": ["rkey", "triggers", "url", "hasSecret", "createdAt"], 44 - "properties": { 45 - "rkey": { 46 - "type": "string", 47 - "maxLength": 64, 48 - "description": "Record key of the io.atcr.hold.webhook record" 49 - }, 50 - "triggers": { 51 - "type": "integer", 52 - "minimum": 0, 53 - "description": "Bitmask of trigger events" 54 - }, 55 - "url": { 56 - "type": "string", 57 - "maxLength": 2048, 58 - "description": "Masked webhook URL (e.g., https://exam***le.com/web***)" 59 - }, 60 - "hasSecret": { 61 - "type": "boolean", 62 - "description": "Whether the webhook has an HMAC signing secret configured" 63 - }, 64 - "createdAt": { 65 - "type": "string", 66 - "format": "datetime", 67 - "description": "RFC3339 timestamp of when the webhook was created" 68 - } 69 - } 70 - }, 71 - "webhookLimits": { 72 - "type": "object", 73 - "required": ["max", "allTriggers"], 74 - "properties": { 75 - "max": { 76 - "type": "integer", 77 - "description": "Maximum number of webhooks allowed for this user's tier (-1 for unlimited)" 78 - }, 79 - "allTriggers": { 80 - "type": "boolean", 81 - "description": "Whether the user's tier allows all trigger types (scan:all, scan:changed). Free tiers only get scan:first." 82 - } 83 - } 84 - } 85 - } 86 - }
-41
lexicons/io/atcr/hold/testWebhook.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "io.atcr.hold.testWebhook", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "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.", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["rkey"], 13 - "properties": { 14 - "rkey": { 15 - "type": "string", 16 - "maxLength": 64, 17 - "description": "Record key of the io.atcr.hold.webhook to test" 18 - } 19 - } 20 - } 21 - }, 22 - "output": { 23 - "encoding": "application/json", 24 - "schema": { 25 - "type": "object", 26 - "required": ["success"], 27 - "properties": { 28 - "success": { 29 - "type": "boolean", 30 - "description": "Whether the test delivery received a 2xx response" 31 - } 32 - } 33 - } 34 - }, 35 - "errors": [ 36 - { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" }, 37 - { "name": "Unauthorized", "description": "Webhook belongs to a different user" } 38 - ] 39 - } 40 - } 41 - }
+53
lexicons/io/atcr/hold/updateCrewTier.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.updateCrewTier", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update a crew member's tier. Only accepts requests from the trusted appview.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["userDid", "tierRank"], 13 + "properties": { 14 + "userDid": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the crew member whose tier is being updated." 18 + }, 19 + "tierRank": { 20 + "type": "integer", 21 + "minimum": 0, 22 + "description": "Tier rank index (0-based, maps to hold tier list by position)." 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["tierName"], 32 + "properties": { 33 + "tierName": { 34 + "type": "string", 35 + "maxLength": 64, 36 + "description": "Resolved tier name on this hold." 37 + } 38 + } 39 + } 40 + }, 41 + "errors": [ 42 + { 43 + "name": "AuthRequired", 44 + "description": "Valid appview token required." 45 + }, 46 + { 47 + "name": "UserNotFound", 48 + "description": "User is not a crew member on this hold." 49 + } 50 + ] 51 + } 52 + } 53 + }
-32
lexicons/io/atcr/hold/webhook.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "io.atcr.hold.webhook", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "any", 8 - "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.", 9 - "record": { 10 - "type": "object", 11 - "required": ["userDid", "triggers", "createdAt"], 12 - "properties": { 13 - "userDid": { 14 - "type": "string", 15 - "format": "did", 16 - "description": "DID of the webhook owner" 17 - }, 18 - "triggers": { 19 - "type": "integer", 20 - "minimum": 0, 21 - "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" 22 - }, 23 - "createdAt": { 24 - "type": "string", 25 - "format": "datetime", 26 - "description": "RFC3339 timestamp of when the webhook was created" 27 - } 28 - } 29 - } 30 - } 31 - } 32 - }
-42
lexicons/io/atcr/sailor/webhook.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "io.atcr.sailor.webhook", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "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.", 9 - "record": { 10 - "type": "object", 11 - "required": ["holdDid", "triggers", "privateCid", "createdAt"], 12 - "properties": { 13 - "holdDid": { 14 - "type": "string", 15 - "format": "did", 16 - "description": "DID of the hold where the webhook is configured" 17 - }, 18 - "triggers": { 19 - "type": "integer", 20 - "minimum": 0, 21 - "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed" 22 - }, 23 - "privateCid": { 24 - "type": "string", 25 - "maxLength": 128, 26 - "description": "CID of the corresponding io.atcr.hold.webhook record on the hold" 27 - }, 28 - "createdAt": { 29 - "type": "string", 30 - "format": "datetime", 31 - "description": "RFC3339 timestamp of when the webhook was created" 32 - }, 33 - "updatedAt": { 34 - "type": "string", 35 - "format": "datetime", 36 - "description": "RFC3339 timestamp of when the webhook was last updated" 37 - } 38 - } 39 - } 40 - } 41 - } 42 - }
+3 -3
lexicons/io/atcr/tag.json
··· 8 8 "key": "any", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["repository", "tag", "createdAt"], 11 + "required": ["repository", "tag"], 12 12 "properties": { 13 13 "repository": { 14 14 "type": "string", ··· 30 30 "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.", 31 31 "maxLength": 128 32 32 }, 33 - "createdAt": { 33 + "updatedAt": { 34 34 "type": "string", 35 35 "format": "datetime", 36 - "description": "Tag creation timestamp" 36 + "description": "Timestamp of last tag update" 37 37 } 38 38 } 39 39 }
+24 -1
pkg/appview/config.go
··· 16 16 "github.com/distribution/distribution/v3/configuration" 17 17 "github.com/spf13/viper" 18 18 19 + "atcr.io/pkg/billing" 19 20 "atcr.io/pkg/config" 20 21 ) 21 22 ··· 31 32 Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` 32 33 CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."` 33 34 Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` 35 + Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` 34 36 Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility 35 37 } 36 38 ··· 59 61 60 62 // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display). 61 63 RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."` 64 + 65 + // DIDs of holds this appview manages billing for. 66 + ManagedHolds []string `yaml:"managed_holds" comment:"DIDs of holds this appview manages billing for. Tier updates are pushed to these holds."` 62 67 } 63 68 64 69 // UIConfig defines web UI settings ··· 97 102 // Sync existing records from PDS on startup. 98 103 BackfillEnabled bool `yaml:"backfill_enabled" comment:"Sync existing records from PDS on startup."` 99 104 105 + // How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup. 106 + 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."` 107 + 100 108 // Relay endpoints for backfill, tried in order on failure. 101 109 RelayEndpoints []string `yaml:"relay_endpoints" comment:"Relay endpoints for backfill, tried in order on failure."` 102 110 } ··· 146 154 v.SetDefault("server.client_short_name", "ATCR") 147 155 v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key") 148 156 v.SetDefault("server.registry_domains", []string{}) 157 + v.SetDefault("server.managed_holds", []string{}) 149 158 150 159 // UI defaults 151 160 v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db") ··· 166 175 "wss://jetstream1.us-east.bsky.network/subscribe", 167 176 }) 168 177 v.SetDefault("jetstream.backfill_enabled", true) 178 + v.SetDefault("jetstream.backfill_interval", "24h") 169 179 v.SetDefault("jetstream.relay_endpoints", []string{ 170 180 "https://relay1.us-east.bsky.network", 171 181 "https://relay1.us-west.bsky.network", ··· 199 209 200 210 // ExampleYAML returns a fully-commented YAML configuration with default values. 201 211 func ExampleYAML() ([]byte, error) { 202 - return config.MarshalCommentedYAML("ATCR AppView Configuration", DefaultConfig()) 212 + cfg := DefaultConfig() 213 + 214 + // Populate example billing tiers so operators see the structure 215 + cfg.Billing.Currency = "usd" 216 + cfg.Billing.SuccessURL = "{base_url}/settings#storage" 217 + cfg.Billing.CancelURL = "{base_url}/settings#storage" 218 + cfg.Billing.OwnerBadge = true 219 + cfg.Billing.Tiers = []billing.BillingTierConfig{ 220 + {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1}, 221 + {Name: "bosun", Description: "More storage with scan-on-push", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true}, 222 + {Name: "quartermaster", Description: "Maximum storage for power users", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true}, 223 + } 224 + 225 + return config.MarshalCommentedYAML("ATCR AppView Configuration", cfg) 203 226 } 204 227 205 228 // LoadConfig builds a complete configuration using Viper layered loading:
+30 -6
pkg/appview/db/annotations.go
··· 1 1 package db 2 2 3 - import "time" 3 + import ( 4 + "strings" 5 + "time" 6 + ) 4 7 5 8 // GetRepositoryAnnotations retrieves all annotations for a repository 6 9 func GetRepositoryAnnotations(db DBTX, did, repository string) (map[string]string, error) { ··· 26 29 return annotations, rows.Err() 27 30 } 28 31 29 - // UpsertRepositoryAnnotations replaces all annotations for a repository 32 + // UpsertRepositoryAnnotations upserts annotations for a repository. 33 + // Stale keys not present in the new map are deleted. 34 + // Unchanged values are skipped to avoid unnecessary writes. 30 35 // Only called when manifest has at least one non-empty annotation. 31 36 // Atomicity is provided by the caller's transaction when used during backfill. 32 37 func UpsertRepositoryAnnotations(db DBTX, did, repository string, annotations map[string]string) error { 33 - // Delete existing annotations 38 + // Delete keys that are no longer in the annotation set 39 + if len(annotations) == 0 { 40 + _, err := db.Exec(` 41 + DELETE FROM repository_annotations 42 + WHERE did = ? AND repository = ? 43 + `, did, repository) 44 + return err 45 + } 46 + 47 + // Build placeholders for the NOT IN clause 48 + placeholders := make([]string, 0, len(annotations)) 49 + args := []any{did, repository} 50 + for key := range annotations { 51 + placeholders = append(placeholders, "?") 52 + args = append(args, key) 53 + } 34 54 _, err := db.Exec(` 35 55 DELETE FROM repository_annotations 36 - WHERE did = ? AND repository = ? 37 - `, did, repository) 56 + WHERE did = ? AND repository = ? AND key NOT IN (`+strings.Join(placeholders, ",")+`) 57 + `, args...) 38 58 if err != nil { 39 59 return err 40 60 } 41 61 42 - // Insert new annotations 62 + // Upsert each annotation, only writing when value changed 43 63 stmt, err := db.Prepare(` 44 64 INSERT INTO repository_annotations (did, repository, key, value, updated_at) 45 65 VALUES (?, ?, ?, ?, ?) 66 + ON CONFLICT(did, repository, key) DO UPDATE SET 67 + value = excluded.value, 68 + updated_at = excluded.updated_at 69 + WHERE excluded.value != repository_annotations.value 46 70 `) 47 71 if err != nil { 48 72 return err
+23 -89
pkg/appview/db/hold_store.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "encoding/json" 6 5 "fmt" 7 6 "strings" 8 7 "time" ··· 20 19 21 20 // HoldCaptainRecord represents a cached captain record from a hold's PDS 22 21 type HoldCaptainRecord struct { 23 - HoldDID string `json:"-"` // Set manually, not from JSON 24 - OwnerDID string `json:"owner"` 25 - Public bool `json:"public"` 26 - AllowAllCrew bool `json:"allowAllCrew"` 27 - DeployedAt string `json:"deployedAt"` 28 - Region string `json:"region"` 29 - Successor string `json:"successor"` // DID of successor hold (migration redirect) 30 - SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]' 31 - UpdatedAt time.Time `json:"-"` // Set manually, not from JSON 22 + HoldDID string `json:"-"` // Set manually, not from JSON 23 + OwnerDID string `json:"owner"` 24 + Public bool `json:"public"` 25 + AllowAllCrew bool `json:"allowAllCrew"` 26 + DeployedAt string `json:"deployedAt"` 27 + Region string `json:"region"` 28 + Successor string `json:"successor"` // DID of successor hold (migration redirect) 29 + UpdatedAt time.Time `json:"-"` // Set manually, not from JSON 32 30 } 33 31 34 32 // GetCaptainRecord retrieves a captain record from the cache ··· 36 34 func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) { 37 35 query := ` 38 36 SELECT hold_did, owner_did, public, allow_all_crew, 39 - deployed_at, region, successor, supporter_badge_tiers, updated_at 37 + deployed_at, region, successor, updated_at 40 38 FROM hold_captain_records 41 39 WHERE hold_did = ? 42 40 ` 43 41 44 42 var record HoldCaptainRecord 45 - var deployedAt, region, successor, supporterBadgeTiers sql.NullString 43 + var deployedAt, region, successor sql.NullString 46 44 47 45 err := db.QueryRow(query, holdDID).Scan( 48 46 &record.HoldDID, ··· 52 50 &deployedAt, 53 51 &region, 54 52 &successor, 55 - &supporterBadgeTiers, 56 53 &record.UpdatedAt, 57 54 ) 58 55 ··· 74 71 if successor.Valid { 75 72 record.Successor = successor.String 76 73 } 77 - if supporterBadgeTiers.Valid { 78 - record.SupporterBadgeTiers = supporterBadgeTiers.String 79 - } 80 74 81 75 return &record, nil 82 76 } ··· 86 80 query := ` 87 81 INSERT INTO hold_captain_records ( 88 82 hold_did, owner_did, public, allow_all_crew, 89 - deployed_at, region, successor, supporter_badge_tiers, updated_at 90 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 83 + deployed_at, region, successor, updated_at 84 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 91 85 ON CONFLICT(hold_did) DO UPDATE SET 92 86 owner_did = excluded.owner_did, 93 87 public = excluded.public, ··· 95 89 deployed_at = excluded.deployed_at, 96 90 region = excluded.region, 97 91 successor = excluded.successor, 98 - supporter_badge_tiers = excluded.supporter_badge_tiers, 99 92 updated_at = excluded.updated_at 93 + WHERE excluded.owner_did != hold_captain_records.owner_did 94 + OR excluded.public != hold_captain_records.public 95 + OR excluded.allow_all_crew != hold_captain_records.allow_all_crew 96 + OR excluded.deployed_at IS NOT hold_captain_records.deployed_at 97 + OR excluded.region IS NOT hold_captain_records.region 98 + OR excluded.successor IS NOT hold_captain_records.successor 100 99 ` 101 100 102 101 _, err := db.Exec(query, ··· 107 106 nullString(record.DeployedAt), 108 107 nullString(record.Region), 109 108 nullString(record.Successor), 110 - nullString(record.SupporterBadgeTiers), 111 109 record.UpdatedAt, 112 110 ) 113 111 ··· 118 116 return nil 119 117 } 120 118 121 - // HasSupporterBadge checks if a given tier is in the hold's supporter badge tiers list. 122 - func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool { 123 - if r.SupporterBadgeTiers == "" || tier == "" { 124 - return false 125 - } 126 - var tiers []string 127 - if err := json.Unmarshal([]byte(r.SupporterBadgeTiers), &tiers); err != nil { 128 - return false 129 - } 130 - for _, t := range tiers { 131 - if t == tier { 132 - return true 133 - } 134 - } 135 - return false 136 - } 137 - 138 - // normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators. 139 - // This is a local copy to avoid importing atproto (prevents circular dependencies). 140 - func normalizeDidWeb(did string) string { 141 - if !strings.HasPrefix(did, "did:web:") { 142 - return did 143 - } 144 - host := strings.TrimPrefix(did, "did:web:") 145 - if !strings.Contains(host, "%3A") && strings.Contains(host, ":") { 146 - host = strings.Replace(host, ":", "%3A", 1) 147 - } 148 - return "did:web:" + host 149 - } 150 - 151 - // GetSupporterBadge returns the supporter badge tier name for a user on a specific hold. 152 - // Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible, 153 - // or the user isn't a member of the hold. 154 - func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string { 155 - if holdDID == "" || userDID == "" { 156 - return "" 157 - } 158 - 159 - // Normalize did:web encoding for consistent comparison 160 - holdDID = normalizeDidWeb(holdDID) 161 - 162 - captain, err := GetCaptainRecord(dbConn, holdDID) 163 - if err != nil || captain == nil || captain.SupporterBadgeTiers == "" { 164 - return "" 165 - } 166 - 167 - // If user is the owner and "owner" badge is enabled, show it 168 - if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") { 169 - return "owner" 170 - } 171 - 172 - // Look up crew membership for this user on this hold 173 - memberships, err := GetCrewMemberships(dbConn, userDID) 174 - if err != nil { 175 - return "" 176 - } 177 - 178 - for _, m := range memberships { 179 - if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" { 180 - if captain.HasSupporterBadge(m.Tier) { 181 - return m.Tier 182 - } 183 - return "" 184 - } 185 - } 186 - 187 - return "" 188 - } 189 - 190 119 // GetCrewHoldDID returns the hold DID from the user's most recent crew membership. 191 120 // Used as a fallback when the user's DefaultHoldDID is not cached. 192 121 func GetCrewHoldDID(db DBTX, memberDID string) string { ··· 342 271 tier = excluded.tier, 343 272 added_at = excluded.added_at, 344 273 updated_at = CURRENT_TIMESTAMP 274 + WHERE excluded.rkey != hold_crew_members.rkey 275 + OR excluded.role IS NOT hold_crew_members.role 276 + OR excluded.permissions IS NOT hold_crew_members.permissions 277 + OR excluded.tier IS NOT hold_crew_members.tier 278 + OR excluded.added_at IS NOT hold_crew_members.added_at 345 279 ` 346 280 347 281 _, err := db.Exec(query,
+27
pkg/appview/db/migrations/0015_create_webhooks_and_scans.yaml
··· 1 + description: Add webhooks and scans tables for appview-side webhook management and scan caching 2 + query: | 3 + CREATE TABLE IF NOT EXISTS webhooks ( 4 + id TEXT PRIMARY KEY, 5 + user_did TEXT NOT NULL, 6 + url TEXT NOT NULL, 7 + secret TEXT, 8 + triggers INTEGER NOT NULL DEFAULT 1, 9 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 + FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE 11 + ); 12 + CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did); 13 + CREATE TABLE IF NOT EXISTS scans ( 14 + hold_did TEXT NOT NULL, 15 + manifest_digest TEXT NOT NULL, 16 + user_did TEXT NOT NULL, 17 + repository TEXT NOT NULL, 18 + critical INTEGER NOT NULL DEFAULT 0, 19 + high INTEGER NOT NULL DEFAULT 0, 20 + medium INTEGER NOT NULL DEFAULT 0, 21 + low INTEGER NOT NULL DEFAULT 0, 22 + total INTEGER NOT NULL DEFAULT 0, 23 + scanner_version TEXT, 24 + scanned_at TIMESTAMP NOT NULL, 25 + PRIMARY KEY(hold_did, manifest_digest) 26 + ); 27 + CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
+3
pkg/appview/db/migrations/0016_drop_supporter_badge_tiers.yaml
··· 1 + description: Drop supporter_badge_tiers column (badges now determined by appview billing config) 2 + query: | 3 + ALTER TABLE hold_captain_records DROP COLUMN supporter_badge_tiers;
+263 -10
pkg/appview/db/queries.go
··· 4 4 "database/sql" 5 5 "encoding/json" 6 6 "fmt" 7 + "net/url" 7 8 "strings" 8 9 "time" 9 10 ) ··· 369 370 return &user, nil 370 371 } 371 372 373 + // InsertUserIfNotExists inserts a user record only if it doesn't already exist. 374 + // Used by non-profile collections to avoid unnecessary writes during backfill. 375 + func InsertUserIfNotExists(db DBTX, user *User) error { 376 + _, err := db.Exec(` 377 + INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) 378 + VALUES (?, ?, ?, ?, ?) 379 + ON CONFLICT(did) DO NOTHING 380 + `, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen) 381 + return err 382 + } 383 + 372 384 // UpsertUser inserts or updates a user record 373 385 func UpsertUser(db DBTX, user *User) error { 374 386 _, err := db.Exec(` ··· 592 604 config_digest = excluded.config_digest, 593 605 config_size = excluded.config_size, 594 606 artifact_type = excluded.artifact_type 607 + WHERE excluded.hold_endpoint != manifests.hold_endpoint 608 + OR excluded.schema_version != manifests.schema_version 609 + OR excluded.media_type != manifests.media_type 610 + OR excluded.config_digest IS NOT manifests.config_digest 611 + OR excluded.config_size IS NOT manifests.config_size 612 + OR excluded.artifact_type != manifests.artifact_type 595 613 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 596 614 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 597 615 manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt) ··· 614 632 return id, nil 615 633 } 616 634 617 - // InsertLayer inserts or updates a layer record. 618 - // Uses upsert so backfill re-processing populates new columns (e.g. annotations). 635 + // InsertLayer inserts a layer record, skipping if it already exists. 636 + // Layers are immutable — once created, their digest/size/media_type never change. 619 637 func InsertLayer(db DBTX, layer *Layer) error { 620 638 var annotationsJSON *string 621 639 if len(layer.Annotations) > 0 { ··· 629 647 _, err := db.Exec(` 630 648 INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations) 631 649 VALUES (?, ?, ?, ?, ?, ?) 632 - ON CONFLICT(manifest_id, layer_index) DO UPDATE SET 633 - digest = excluded.digest, 634 - size = excluded.size, 635 - media_type = excluded.media_type, 636 - annotations = excluded.annotations 650 + ON CONFLICT(manifest_id, layer_index) DO NOTHING 637 651 `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON) 638 652 return err 639 653 } ··· 646 660 ON CONFLICT(did, repository, tag) DO UPDATE SET 647 661 digest = excluded.digest, 648 662 created_at = excluded.created_at 663 + WHERE excluded.digest != tags.digest 664 + OR excluded.created_at != tags.created_at 649 665 `, tag.DID, tag.Repository, tag.Tag, tag.Digest, tag.CreatedAt) 650 666 return err 651 667 } ··· 1607 1623 last_pull = excluded.last_pull, 1608 1624 push_count = excluded.push_count, 1609 1625 last_push = excluded.last_push 1626 + WHERE excluded.pull_count != repository_stats.pull_count 1627 + OR excluded.last_pull IS NOT repository_stats.last_pull 1628 + OR excluded.push_count != repository_stats.push_count 1629 + OR excluded.last_push IS NOT repository_stats.last_push 1610 1630 `, stats.DID, stats.Repository, stats.PullCount, stats.LastPull, stats.PushCount, stats.LastPush) 1611 1631 return err 1612 1632 } 1613 1633 1614 - // UpsertStar inserts or updates a star record (idempotent) 1634 + // UpsertStar inserts a star record, skipping if it already exists. 1635 + // Stars are immutable — once created, they don't change. 1615 1636 func UpsertStar(db DBTX, starrerDID, ownerDID, repository string, createdAt time.Time) error { 1616 1637 _, err := db.Exec(` 1617 1638 INSERT INTO stars (starrer_did, owner_did, repository, created_at) 1618 1639 VALUES (?, ?, ?, ?) 1619 - ON CONFLICT(starrer_did, owner_did, repository) DO UPDATE SET 1620 - created_at = excluded.created_at 1640 + ON CONFLICT(starrer_did, owner_did, repository) DO NOTHING 1621 1641 `, starrerDID, ownerDID, repository, createdAt) 1622 1642 return err 1623 1643 } ··· 1957 1977 description = excluded.description, 1958 1978 avatar_cid = excluded.avatar_cid, 1959 1979 updated_at = excluded.updated_at 1980 + WHERE excluded.description IS NOT repo_pages.description 1981 + OR excluded.avatar_cid IS NOT repo_pages.avatar_cid 1960 1982 `, did, repository, description, avatarCID, createdAt, updatedAt) 1961 1983 return err 1962 1984 } ··· 2005 2027 } 2006 2028 return pages, rows.Err() 2007 2029 } 2030 + 2031 + // --- Webhook types and queries --- 2032 + 2033 + // Webhook represents a webhook configuration stored in the appview DB 2034 + type Webhook struct { 2035 + ID string `json:"id"` 2036 + UserDID string `json:"userDid"` 2037 + URL string `json:"url"` 2038 + Secret string `json:"-"` 2039 + Triggers int `json:"triggers"` 2040 + HasSecret bool `json:"hasSecret"` 2041 + CreatedAt time.Time `json:"createdAt"` 2042 + } 2043 + 2044 + // CountWebhooks returns the number of webhooks configured for a user 2045 + func CountWebhooks(db DBTX, userDID string) (int, error) { 2046 + var count int 2047 + err := db.QueryRow(`SELECT COUNT(*) FROM webhooks WHERE user_did = ?`, userDID).Scan(&count) 2048 + return count, err 2049 + } 2050 + 2051 + // ListWebhooks returns webhook configurations for display (masked URLs, no secrets) 2052 + func ListWebhooks(db DBTX, userDID string) ([]Webhook, error) { 2053 + rows, err := db.Query(` 2054 + SELECT id, user_did, url, secret, triggers, created_at 2055 + FROM webhooks WHERE user_did = ? ORDER BY created_at ASC 2056 + `, userDID) 2057 + if err != nil { 2058 + return nil, err 2059 + } 2060 + defer rows.Close() 2061 + 2062 + var webhooks []Webhook 2063 + for rows.Next() { 2064 + var w Webhook 2065 + var secret string 2066 + if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &secret, &w.Triggers, &w.CreatedAt); err != nil { 2067 + continue 2068 + } 2069 + w.HasSecret = secret != "" 2070 + w.URL = maskWebhookURL(w.URL) 2071 + webhooks = append(webhooks, w) 2072 + } 2073 + if webhooks == nil { 2074 + webhooks = []Webhook{} 2075 + } 2076 + return webhooks, rows.Err() 2077 + } 2078 + 2079 + // GetWebhookByID returns a single webhook with full URL and secret (for dispatch/test) 2080 + func GetWebhookByID(db DBTX, id string) (*Webhook, error) { 2081 + var w Webhook 2082 + err := db.QueryRow(` 2083 + SELECT id, user_did, url, secret, triggers, created_at 2084 + FROM webhooks WHERE id = ? 2085 + `, id).Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt) 2086 + if err != nil { 2087 + return nil, err 2088 + } 2089 + w.HasSecret = w.Secret != "" 2090 + return &w, nil 2091 + } 2092 + 2093 + // InsertWebhook creates a new webhook record 2094 + func InsertWebhook(db DBTX, w *Webhook) error { 2095 + _, err := db.Exec(` 2096 + INSERT INTO webhooks (id, user_did, url, secret, triggers, created_at) 2097 + VALUES (?, ?, ?, ?, ?, ?) 2098 + `, w.ID, w.UserDID, w.URL, w.Secret, w.Triggers, w.CreatedAt) 2099 + return err 2100 + } 2101 + 2102 + // DeleteWebhook deletes a webhook by ID, validating ownership 2103 + func DeleteWebhook(db DBTX, id, userDID string) error { 2104 + result, err := db.Exec(`DELETE FROM webhooks WHERE id = ? AND user_did = ?`, id, userDID) 2105 + if err != nil { 2106 + return err 2107 + } 2108 + rows, _ := result.RowsAffected() 2109 + if rows == 0 { 2110 + return fmt.Errorf("webhook not found or not owned by user") 2111 + } 2112 + return nil 2113 + } 2114 + 2115 + // GetWebhooksForUser returns all webhooks with full URL+secret for dispatch 2116 + func GetWebhooksForUser(db DBTX, userDID string) ([]Webhook, error) { 2117 + rows, err := db.Query(` 2118 + SELECT id, user_did, url, secret, triggers, created_at 2119 + FROM webhooks WHERE user_did = ? 2120 + `, userDID) 2121 + if err != nil { 2122 + return nil, err 2123 + } 2124 + defer rows.Close() 2125 + 2126 + var webhooks []Webhook 2127 + for rows.Next() { 2128 + var w Webhook 2129 + if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt); err != nil { 2130 + continue 2131 + } 2132 + w.HasSecret = w.Secret != "" 2133 + webhooks = append(webhooks, w) 2134 + } 2135 + return webhooks, rows.Err() 2136 + } 2137 + 2138 + // maskWebhookURL masks a URL for display (shows scheme + host, hides path/query) 2139 + func maskWebhookURL(rawURL string) string { 2140 + u, err := url.Parse(rawURL) 2141 + if err != nil { 2142 + if len(rawURL) > 30 { 2143 + return rawURL[:30] + "***" 2144 + } 2145 + return rawURL 2146 + } 2147 + masked := u.Scheme + "://" + u.Host 2148 + if u.Path != "" && u.Path != "/" { 2149 + masked += "/***" 2150 + } 2151 + return masked 2152 + } 2153 + 2154 + // --- Scan types and queries --- 2155 + 2156 + // Scan represents a cached scan record from Jetstream 2157 + type Scan struct { 2158 + HoldDID string 2159 + ManifestDigest string 2160 + UserDID string 2161 + Repository string 2162 + Critical int 2163 + High int 2164 + Medium int 2165 + Low int 2166 + Total int 2167 + ScannerVersion string 2168 + ScannedAt time.Time 2169 + } 2170 + 2171 + // UpsertScan inserts or updates a scan record, returning the previous scan for change detection 2172 + func UpsertScan(db DBTX, scan *Scan) (*Scan, error) { 2173 + // Fetch previous scan (if any) before upserting 2174 + var prev *Scan 2175 + var p Scan 2176 + err := db.QueryRow(` 2177 + SELECT hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at 2178 + FROM scans WHERE hold_did = ? AND manifest_digest = ? 2179 + `, scan.HoldDID, scan.ManifestDigest).Scan( 2180 + &p.HoldDID, &p.ManifestDigest, &p.UserDID, &p.Repository, 2181 + &p.Critical, &p.High, &p.Medium, &p.Low, &p.Total, 2182 + &p.ScannerVersion, &p.ScannedAt, 2183 + ) 2184 + if err == nil { 2185 + prev = &p 2186 + } 2187 + 2188 + // Upsert the new scan 2189 + _, err = db.Exec(` 2190 + INSERT INTO scans (hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at) 2191 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 2192 + ON CONFLICT(hold_did, manifest_digest) DO UPDATE SET 2193 + user_did = excluded.user_did, 2194 + repository = excluded.repository, 2195 + critical = excluded.critical, 2196 + high = excluded.high, 2197 + medium = excluded.medium, 2198 + low = excluded.low, 2199 + total = excluded.total, 2200 + scanner_version = excluded.scanner_version, 2201 + scanned_at = excluded.scanned_at 2202 + WHERE excluded.critical != scans.critical 2203 + OR excluded.high != scans.high 2204 + OR excluded.medium != scans.medium 2205 + OR excluded.low != scans.low 2206 + OR excluded.total != scans.total 2207 + OR excluded.scanner_version IS NOT scans.scanner_version 2208 + OR excluded.scanned_at != scans.scanned_at 2209 + `, scan.HoldDID, scan.ManifestDigest, scan.UserDID, scan.Repository, 2210 + scan.Critical, scan.High, scan.Medium, scan.Low, scan.Total, 2211 + scan.ScannerVersion, scan.ScannedAt, 2212 + ) 2213 + if err != nil { 2214 + return nil, fmt.Errorf("failed to upsert scan: %w", err) 2215 + } 2216 + 2217 + return prev, nil 2218 + } 2219 + 2220 + // GetTagByDigest returns the most recent tag for a manifest digest in a user's repository 2221 + func GetTagByDigest(db DBTX, userDID, repository, digest string) (string, error) { 2222 + var tag string 2223 + err := db.QueryRow(` 2224 + SELECT tag FROM tags 2225 + WHERE did = ? AND repository = ? AND digest = ? 2226 + ORDER BY created_at DESC LIMIT 1 2227 + `, userDID, repository, digest).Scan(&tag) 2228 + if err != nil { 2229 + return "", err 2230 + } 2231 + return tag, nil 2232 + } 2233 + 2234 + // IsHoldCaptain returns true if userDID is the owner of any hold in the managedHolds list. 2235 + func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error) { 2236 + if userDID == "" || len(managedHolds) == 0 { 2237 + return false, nil 2238 + } 2239 + 2240 + placeholders := make([]string, len(managedHolds)) 2241 + args := make([]any, 0, len(managedHolds)+1) 2242 + args = append(args, userDID) 2243 + for i, did := range managedHolds { 2244 + placeholders[i] = "?" 2245 + args = append(args, did) 2246 + } 2247 + 2248 + var exists int 2249 + err := db.QueryRow( 2250 + `SELECT 1 FROM hold_captain_records WHERE owner_did = ? AND hold_did IN (`+strings.Join(placeholders, ",")+`) LIMIT 1`, 2251 + args..., 2252 + ).Scan(&exists) 2253 + if err == sql.ErrNoRows { 2254 + return false, nil 2255 + } 2256 + if err != nil { 2257 + return false, err 2258 + } 2259 + return true, nil 2260 + }
+27 -1
pkg/appview/db/schema.sql
··· 186 186 deployed_at TEXT, 187 187 region TEXT, 188 188 successor TEXT, 189 - supporter_badge_tiers TEXT, 190 189 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 191 190 ); 192 191 CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at); ··· 245 244 key_data BLOB NOT NULL, 246 245 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 247 246 ); 247 + 248 + CREATE TABLE IF NOT EXISTS webhooks ( 249 + id TEXT PRIMARY KEY, 250 + user_did TEXT NOT NULL, 251 + url TEXT NOT NULL, 252 + secret TEXT, 253 + triggers INTEGER NOT NULL DEFAULT 1, 254 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 255 + FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE 256 + ); 257 + CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did); 258 + 259 + CREATE TABLE IF NOT EXISTS scans ( 260 + hold_did TEXT NOT NULL, 261 + manifest_digest TEXT NOT NULL, 262 + user_did TEXT NOT NULL, 263 + repository TEXT NOT NULL, 264 + critical INTEGER NOT NULL DEFAULT 0, 265 + high INTEGER NOT NULL DEFAULT 0, 266 + medium INTEGER NOT NULL DEFAULT 0, 267 + low INTEGER NOT NULL DEFAULT 0, 268 + total INTEGER NOT NULL DEFAULT 0, 269 + scanner_version TEXT, 270 + scanned_at TIMESTAMP NOT NULL, 271 + PRIMARY KEY(hold_did, manifest_digest) 272 + ); 273 + CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
+8 -4
pkg/appview/handlers/base.go
··· 7 7 "atcr.io/pkg/appview/db" 8 8 "atcr.io/pkg/appview/holdhealth" 9 9 "atcr.io/pkg/appview/readme" 10 + "atcr.io/pkg/appview/webhooks" 10 11 "atcr.io/pkg/auth/oauth" 12 + "atcr.io/pkg/billing" 11 13 "github.com/bluesky-social/indigo/atproto/identity" 12 14 ) 13 15 ··· 25 27 ReadOnlyDB *sql.DB // Read-only access 26 28 27 29 // Services 28 - Refresher *oauth.Refresher 29 - HealthChecker *holdhealth.Checker 30 - ReadmeFetcher *readme.Fetcher 31 - Directory identity.Directory 30 + Refresher *oauth.Refresher 31 + HealthChecker *holdhealth.Checker 32 + ReadmeFetcher *readme.Fetcher 33 + Directory identity.Directory 34 + BillingManager *billing.Manager 35 + WebhookDispatcher *webhooks.Dispatcher 32 36 33 37 // Stores 34 38 SessionStore *db.SessionStore
+129 -64
pkg/appview/handlers/settings.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "encoding/json" 7 - "html/template" 7 + "fmt" 8 8 "log/slog" 9 9 "net/http" 10 10 "net/url" ··· 14 14 "atcr.io/pkg/appview/db" 15 15 "atcr.io/pkg/appview/middleware" 16 16 "atcr.io/pkg/appview/storage" 17 + "atcr.io/pkg/appview/webhooks" 17 18 "atcr.io/pkg/atproto" 19 + 18 20 "github.com/bluesky-social/indigo/atproto/syntax" 19 21 ) 20 22 ··· 26 28 Membership string `json:"membership"` 27 29 Permissions []string `json:"permissions,omitempty"` 28 30 Status string `json:"status"` // "" = unknown, "online", "offline" 31 + IsActive bool `json:"isActive"` 29 32 } 30 33 31 34 // SettingsHandler handles the settings page ··· 61 64 62 65 slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold) 63 66 64 - // Get available holds for dropdown 65 - var ownedHolds, crewHolds, eligibleHolds []HoldDisplay 66 - holdDataMap := make(map[string]HoldDisplay) 67 + // Get available holds 68 + var activeHold *HoldDisplay 69 + var otherHolds, allHolds []HoldDisplay 67 70 68 71 if h.DB != nil { 69 72 availableHolds, err := db.GetAvailableHolds(h.DB, user.DID) 70 73 if err != nil { 71 74 slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err) 72 75 } else { 73 - // Group holds by membership type 74 76 for _, hold := range availableHolds { 75 77 display := HoldDisplay{ 76 78 DID: hold.HoldDID, 77 79 DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID), 78 80 Region: hold.Region, 79 81 Membership: hold.Membership, 82 + IsActive: hold.HoldDID == profile.DefaultHold, 80 83 } 81 84 82 85 // Parse permissions JSON if present ··· 86 89 } 87 90 } 88 91 89 - // Check cached health status (non-blocking, nil = no data yet) 92 + // Check health status (uses cache if available, otherwise pings on-demand) 90 93 if h.HealthChecker != nil { 91 - if status := h.HealthChecker.GetCachedStatus(hold.HoldDID); status != nil { 94 + if status := h.HealthChecker.GetStatus(r.Context(), hold.HoldDID); status != nil { 92 95 if status.Reachable { 93 96 display.Status = "online" 94 97 } else { ··· 97 100 } 98 101 } 99 102 100 - // Add to data map for JavaScript 101 - holdDataMap[hold.HoldDID] = display 103 + // All holds go in dropdown list 104 + allHolds = append(allHolds, display) 102 105 103 - // Group by membership type 104 - switch hold.Membership { 105 - case "owner": 106 - ownedHolds = append(ownedHolds, display) 107 - case "crew": 108 - crewHolds = append(crewHolds, display) 109 - case "eligible": 110 - eligibleHolds = append(eligibleHolds, display) 106 + // Separate active from other member holds (skip eligible) 107 + if hold.Membership != "eligible" { 108 + if display.IsActive { 109 + holdCopy := display 110 + activeHold = &holdCopy 111 + } else { 112 + otherHolds = append(otherHolds, display) 113 + } 111 114 } 112 115 } 113 116 } 114 117 } 115 118 116 - // Serialize hold data for JavaScript 117 - holdDataJSON, _ := json.Marshal(holdDataMap) 119 + // Fetch webhooks (local DB read) 120 + webhooksData := h.buildWebhooksData(user.DID) 118 121 119 - // Check if current hold needs to be shown separately (not in discovered holds) 120 - _, currentHoldDiscovered := holdDataMap[profile.DefaultHold] 121 - showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered 122 - 123 - // Look up AppView default hold details from database 124 - appViewDefaultDisplay := resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, h.DefaultHoldDID) 125 - var appViewDefaultRegion string 126 - if h.DefaultHoldDID != "" && h.DB != nil { 127 - if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil { 128 - appViewDefaultRegion = captain.Region 129 - } 130 - } 122 + // Fetch subscription info (Stripe with in-memory cache) 123 + subscriptionData := h.buildSubscriptionDisplay(user.DID) 131 124 132 125 meta := NewPageMeta( 133 126 "Settings - "+h.ClientShortName, ··· 144 137 PDSEndpoint string 145 138 DefaultHold string 146 139 } 147 - CurrentHoldDID string 148 - CurrentHoldDisplay string 149 - ShowCurrentHold bool 150 - AppViewDefaultHoldDID string 151 - AppViewDefaultHoldDisplay string 152 - AppViewDefaultRegion string 153 - OwnedHolds []HoldDisplay 154 - CrewHolds []HoldDisplay 155 - EligibleHolds []HoldDisplay 156 - HoldDataJSON template.JS 140 + ActiveHold *HoldDisplay 141 + OtherHolds []HoldDisplay 142 + AllHolds []HoldDisplay 143 + WebhooksData webhooksTemplateData 144 + Subscription SubscriptionDisplay 157 145 }{ 158 - PageData: NewPageData(r, &h.BaseUIHandler), 159 - Meta: meta, 160 - CurrentHoldDID: profile.DefaultHold, 161 - CurrentHoldDisplay: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, profile.DefaultHold), 162 - ShowCurrentHold: showCurrentHold, 163 - AppViewDefaultHoldDID: h.DefaultHoldDID, 164 - AppViewDefaultHoldDisplay: appViewDefaultDisplay, 165 - AppViewDefaultRegion: appViewDefaultRegion, 166 - OwnedHolds: ownedHolds, 167 - CrewHolds: crewHolds, 168 - EligibleHolds: eligibleHolds, 169 - HoldDataJSON: template.JS(holdDataJSON), 146 + PageData: NewPageData(r, &h.BaseUIHandler), 147 + Meta: meta, 148 + ActiveHold: activeHold, 149 + OtherHolds: otherHolds, 150 + AllHolds: allHolds, 151 + WebhooksData: webhooksData, 152 + Subscription: subscriptionData, 170 153 } 171 154 172 155 data.Profile.Handle = user.Handle ··· 180 163 } 181 164 } 182 165 166 + // webhooksTemplateData is the data passed to the webhooks_list template. 167 + type webhooksTemplateData struct { 168 + Webhooks []webhookEntry 169 + Limits webhookLimits 170 + ContainerID string 171 + TriggerInfo []triggerInfo 172 + } 173 + 174 + // buildWebhooksData fetches webhook data for SSR in the settings page. 175 + func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData { 176 + data := webhooksTemplateData{ 177 + ContainerID: "webhooks-content", 178 + TriggerInfo: []triggerInfo{ 179 + {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 180 + {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 181 + {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 182 + }, 183 + } 184 + 185 + maxWebhooks, allTriggers := h.getWebhookLimits(userDID) 186 + data.Limits = webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers} 187 + 188 + webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID) 189 + if err != nil { 190 + slog.Warn("Failed to list webhooks for settings SSR", "error", err) 191 + return data 192 + } 193 + 194 + data.Webhooks = make([]webhookEntry, len(webhookList)) 195 + for i, wh := range webhookList { 196 + data.Webhooks[i] = webhookEntry{ 197 + ID: wh.ID, 198 + Triggers: wh.Triggers, 199 + URL: wh.URL, 200 + HasSecret: wh.HasSecret, 201 + CreatedAt: wh.CreatedAt.Format(time.RFC3339), 202 + HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, 203 + HasAll: wh.Triggers&webhooks.TriggerAll != 0, 204 + HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, 205 + } 206 + } 207 + 208 + return data 209 + } 210 + 211 + // buildSubscriptionDisplay fetches subscription info for SSR in the settings page. 212 + func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionDisplay { 213 + if h.BillingManager == nil || !h.BillingManager.Enabled() { 214 + return SubscriptionDisplay{HideBilling: true} 215 + } 216 + 217 + info, err := h.BillingManager.GetSubscriptionInfo(userDID) 218 + if err != nil { 219 + slog.Warn("Failed to get subscription info for settings SSR", "did", userDID, "error", err) 220 + return SubscriptionDisplay{HideBilling: true} 221 + } 222 + 223 + if !info.PaymentsEnabled { 224 + return SubscriptionDisplay{HideBilling: true} 225 + } 226 + 227 + display := SubscriptionDisplay{ 228 + UserDID: info.UserDID, 229 + CurrentTier: info.CurrentTier, 230 + PaymentsEnabled: info.PaymentsEnabled, 231 + SubscriptionID: info.SubscriptionID, 232 + BillingInterval: info.BillingInterval, 233 + } 234 + 235 + for _, tier := range info.Tiers { 236 + td := TierDisplay{ 237 + ID: tier.ID, 238 + Name: tier.Name, 239 + Description: tier.Description, 240 + Features: tier.Features, 241 + PriceCentsMonthly: tier.PriceCentsMonthly, 242 + PriceCentsYearly: tier.PriceCentsYearly, 243 + IsCurrent: tier.IsCurrent, 244 + } 245 + if tier.PriceCentsMonthly > 0 { 246 + td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100) 247 + } 248 + if tier.PriceCentsYearly > 0 { 249 + td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100) 250 + } 251 + display.Tiers = append(display.Tiers, td) 252 + } 253 + 254 + return display 255 + } 256 + 183 257 // resolveHoldDisplayName resolves a hold DID to a human-readable handle via the 184 258 // identity directory. Falls back to domain extraction (did:web) or truncation (did:plc). 185 259 func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) string { ··· 302 376 } 303 377 } 304 378 379 + w.Header().Set("HX-Refresh", "true") 305 380 w.Header().Set("Content-Type", "text/html") 306 381 if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 307 382 "Type": "success", ··· 340 415 captainRecord.HoldDID = holdDID 341 416 captainRecord.UpdatedAt = time.Now() 342 417 343 - // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-") 344 - var raw struct { 345 - SupporterBadgeTiers []string `json:"supporterBadgeTiers"` 346 - } 347 - if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 { 348 - if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil { 349 - captainRecord.SupporterBadgeTiers = string(jsonBytes) 350 - } 351 - } 352 - 353 418 if err := db.UpsertCaptainRecord(dbConn, &captainRecord); err != nil { 354 419 slog.Debug("Failed to cache captain record on refresh", "hold_did", holdDID, "error", err) 355 420 return 356 421 } 357 422 358 - slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers) 423 + slog.Info("Refreshed captain record for hold", "hold_did", holdDID) 359 424 } 360 425 361 426 // refreshCrewMembership fetches a user's crew record from a hold and caches it locally.
+28 -25
pkg/appview/handlers/storage.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 9 - "atcr.io/pkg/appview/db" 10 9 "atcr.io/pkg/appview/middleware" 11 10 "atcr.io/pkg/appview/storage" 12 11 "atcr.io/pkg/atproto" ··· 83 82 return 84 83 } 85 84 86 - // Render the stats partial 85 + // Render compact or full stats 86 + if r.URL.Query().Get("compact") == "true" { 87 + h.renderCompact(w, stats) 88 + return 89 + } 87 90 h.renderStats(w, stats, holdDID) 91 + } 92 + 93 + func (h *StorageHandler) renderCompact(w http.ResponseWriter, stats QuotaStats) { 94 + w.Header().Set("Content-Type", "text/html") 95 + if stats.TotalSize == 0 && stats.UniqueBlobs == 0 { 96 + fmt.Fprint(w, `<span class="text-base-content/40">No data</span>`) 97 + } else { 98 + fmt.Fprint(w, humanizeBytes(stats.TotalSize)) 99 + } 88 100 } 89 101 90 102 func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, holdDID string) { ··· 102 114 } 103 115 } 104 116 105 - // Check if user's tier earns a supporter badge on this hold 106 - var hasSupporterBadge bool 107 - if stats.Tier != "" && h.ReadOnlyDB != nil && holdDID != "" { 108 - badge := db.GetSupporterBadge(h.ReadOnlyDB, stats.UserDID, holdDID) 109 - hasSupporterBadge = badge != "" 110 - } 111 - 112 117 data := struct { 113 - UniqueBlobs int 114 - TotalSize int64 115 - HumanSize string 116 - HasLimit bool 117 - HumanLimit string 118 - UsagePercent int 119 - Tier string 120 - HasSupporterBadge bool 118 + UniqueBlobs int 119 + TotalSize int64 120 + HumanSize string 121 + HasLimit bool 122 + HumanLimit string 123 + UsagePercent int 124 + Tier string 121 125 }{ 122 - UniqueBlobs: stats.UniqueBlobs, 123 - TotalSize: stats.TotalSize, 124 - HumanSize: humanizeBytes(stats.TotalSize), 125 - HasLimit: hasLimit, 126 - HumanLimit: humanLimit, 127 - UsagePercent: usagePercent, 128 - Tier: stats.Tier, 129 - HasSupporterBadge: hasSupporterBadge, 126 + UniqueBlobs: stats.UniqueBlobs, 127 + TotalSize: stats.TotalSize, 128 + HumanSize: humanizeBytes(stats.TotalSize), 129 + HasLimit: hasLimit, 130 + HumanLimit: humanLimit, 131 + UsagePercent: usagePercent, 132 + Tier: stats.Tier, 130 133 } 131 134 132 135 w.Header().Set("Content-Type", "text/html")
+46 -278
pkg/appview/handlers/subscription.go
··· 1 1 package handlers 2 2 3 3 import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 4 "log/slog" 8 5 "net/http" 9 6 10 7 "atcr.io/pkg/appview/middleware" 11 - "atcr.io/pkg/appview/storage" 12 - "atcr.io/pkg/atproto" 13 - "atcr.io/pkg/auth" 8 + "atcr.io/pkg/billing" 14 9 ) 15 10 16 - // SubscriptionInfo mirrors the hold's billing.SubscriptionInfo for JSON decoding. 17 - type SubscriptionInfo struct { 18 - UserDID string `json:"userDid"` 19 - CurrentTier string `json:"currentTier"` 20 - CrewTier string `json:"crewTier,omitempty"` 21 - CurrentUsage int64 `json:"currentUsage"` 22 - CurrentLimit *int64 `json:"currentLimit,omitempty"` 23 - PaymentsEnabled bool `json:"paymentsEnabled"` 24 - Tiers []TierInfo `json:"tiers"` 25 - SubscriptionID string `json:"subscriptionId,omitempty"` 26 - BillingInterval string `json:"billingInterval,omitempty"` 27 - Error string `json:"error,omitempty"` 28 - HideBilling bool `json:"-"` // hide entire section (no billing support) 29 - HoldDisplayName string `json:"-"` // human-readable hold name for display 30 - } 31 - 32 - // TierInfo mirrors the hold's billing.TierInfo. 33 - type TierInfo struct { 34 - ID string `json:"id"` 35 - Name string `json:"name"` 36 - Description string `json:"description,omitempty"` 37 - QuotaBytes int64 `json:"quotaBytes"` 38 - QuotaFormatted string `json:"quotaFormatted"` 39 - PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` 40 - PriceCentsYearly int `json:"priceCentsYearly,omitempty"` 41 - PriceFormatted string `json:"-"` // computed in handler, e.g., "$5/month" 42 - IsCurrent bool `json:"isCurrent,omitempty"` 43 - } 44 - 45 - // SubscriptionHandler returns subscription info as HTML for HTMX. 46 - type SubscriptionHandler struct { 47 - BaseUIHandler 48 - } 49 - 50 - func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 51 - user := middleware.GetUser(r) 52 - if user == nil { 53 - h.renderHidden(w) 54 - return 55 - } 56 - 57 - // Use hold_did query param if provided (for previewing other holds), 58 - // otherwise fall back to the user's saved default hold from their profile. 59 - holdDID := r.URL.Query().Get("hold_did") 60 - if holdDID == "" { 61 - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 62 - profile, err := storage.GetProfile(r.Context(), client) 63 - if err != nil { 64 - slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err) 65 - h.renderHidden(w) 66 - return 67 - } 68 - holdDID = h.DefaultHoldDID 69 - if profile != nil && profile.DefaultHold != "" { 70 - holdDID = profile.DefaultHold 71 - } 72 - } 73 - 74 - if holdDID == "" { 75 - h.renderHidden(w) 76 - return 77 - } 78 - 79 - // Resolve hold DID to endpoint 80 - holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) 81 - if err != nil { 82 - slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) 83 - h.renderHidden(w) 84 - return 85 - } 86 - 87 - // Fetch subscription info from hold (public endpoint, no auth needed) 88 - subURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getSubscriptionInfo?userDid=%s", holdEndpoint, user.DID) 89 - resp, err := http.Get(subURL) 90 - if err != nil { 91 - slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err) 92 - h.renderHidden(w) 93 - return 94 - } 95 - defer resp.Body.Close() 96 - 97 - if resp.StatusCode != http.StatusOK { 98 - slog.Warn("Hold returned error for subscription", "status", resp.StatusCode) 99 - h.renderHidden(w) 100 - return 101 - } 102 - 103 - var info SubscriptionInfo 104 - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { 105 - slog.Warn("Failed to decode subscription info", "error", err) 106 - h.renderHidden(w) 107 - return 108 - } 109 - 110 - if !info.PaymentsEnabled { 111 - h.renderHidden(w) 112 - return 113 - } 114 - 115 - // Set hold display name so users know which hold the subscription applies to 116 - info.HoldDisplayName = resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, holdDID) 117 - 118 - // Format prices for display 119 - // Note: -1 means "has price, fetch from Stripe" (placeholder from hold) 120 - for i := range info.Tiers { 121 - tier := &info.Tiers[i] 122 - hasMonthly := tier.PriceCentsMonthly != 0 123 - hasYearly := tier.PriceCentsYearly != 0 124 - 125 - switch { 126 - case hasMonthly && tier.PriceCentsMonthly > 0: 127 - tier.PriceFormatted = fmt.Sprintf("$%d/month", tier.PriceCentsMonthly/100) 128 - case hasYearly && tier.PriceCentsYearly > 0: 129 - tier.PriceFormatted = fmt.Sprintf("$%d/year", tier.PriceCentsYearly/100) 130 - case hasMonthly || hasYearly: 131 - // Has price but we don't know the amount (-1 sentinel) 132 - tier.PriceFormatted = "Paid" 133 - default: 134 - tier.PriceFormatted = "Free" 135 - } 136 - } 137 - 138 - // Render the subscription info 139 - h.renderInfo(w, info) 11 + // SubscriptionDisplay is the template-friendly subscription data. 12 + type SubscriptionDisplay struct { 13 + UserDID string 14 + CurrentTier string 15 + PaymentsEnabled bool 16 + Tiers []TierDisplay 17 + SubscriptionID string 18 + BillingInterval string 19 + HideBilling bool 140 20 } 141 21 142 - func (h *SubscriptionHandler) renderInfo(w http.ResponseWriter, info SubscriptionInfo) { 143 - w.Header().Set("Content-Type", "text/html") 144 - if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil { 145 - slog.Error("Failed to render subscription template", "error", err) 146 - h.renderError(w, "Failed to render template") 147 - } 148 - } 149 - 150 - func (h *SubscriptionHandler) renderHidden(w http.ResponseWriter) { 151 - w.Header().Set("Content-Type", "text/html") 152 - info := SubscriptionInfo{HideBilling: true} 153 - if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil { 154 - slog.Error("Failed to render hidden subscription template", "error", err) 155 - } 156 - } 157 - 158 - func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) { 159 - w.Header().Set("Content-Type", "text/html") 160 - fmt.Fprintf(w, `<div class="alert alert-error"><svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#alert-circle"></use></svg> %s</div>`, message) 22 + // TierDisplay is a template-friendly tier. 23 + type TierDisplay struct { 24 + ID string 25 + Name string 26 + Description string 27 + Features []string 28 + PriceCentsMonthly int 29 + PriceCentsYearly int 30 + PriceMonthly string // e.g. "$5/mo" 31 + PriceYearly string // e.g. "$50/yr" 32 + IsCurrent bool 161 33 } 162 34 163 - // SubscriptionCheckoutHandler redirects to hold's Stripe checkout. 35 + // SubscriptionCheckoutHandler redirects to Stripe checkout. 164 36 type SubscriptionCheckoutHandler struct { 165 37 BaseUIHandler 166 38 } ··· 172 44 return 173 45 } 174 46 47 + if h.BillingManager == nil || !h.BillingManager.Enabled() { 48 + http.Error(w, "Billing not available", http.StatusNotFound) 49 + return 50 + } 51 + 175 52 tier := r.URL.Query().Get("tier") 176 53 if tier == "" { 177 54 http.Error(w, "tier parameter required", http.StatusBadRequest) 178 55 return 179 56 } 180 57 181 - // Get user's default hold 182 - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 183 - profile, err := storage.GetProfile(r.Context(), client) 184 - if err != nil { 185 - http.Error(w, "Failed to load profile", http.StatusInternalServerError) 186 - return 58 + interval := r.URL.Query().Get("interval") 59 + if interval == "" { 60 + interval = "monthly" 187 61 } 188 62 189 - holdDID := h.DefaultHoldDID 190 - if profile != nil && profile.DefaultHold != "" { 191 - holdDID = profile.DefaultHold 192 - } 193 - 194 - if holdDID == "" { 195 - http.Error(w, "No default hold configured", http.StatusBadRequest) 196 - return 197 - } 198 - 199 - // Resolve hold endpoint 200 - holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) 63 + resp, err := h.BillingManager.CreateCheckoutSession(r, user.DID, user.Handle, &billing.CheckoutSessionRequest{ 64 + Tier: tier, 65 + Interval: interval, 66 + }) 201 67 if err != nil { 202 - slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) 203 - http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) 204 - return 205 - } 206 - 207 - // Get service token for the hold 208 - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 209 - if err != nil { 210 - slog.Warn("Failed to get service token for checkout", "did", user.DID, "holdDid", holdDID, "error", err) 211 - http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError) 212 - return 213 - } 214 - 215 - // Call hold's checkout endpoint 216 - checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint) 217 - reqBody := map[string]string{ 218 - "tier": tier, 219 - "returnUrl": h.SiteURL + "/settings#storage", 220 - } 221 - bodyBytes, _ := json.Marshal(reqBody) 222 - 223 - req, err := http.NewRequestWithContext(r.Context(), "POST", checkoutURL, bytes.NewReader(bodyBytes)) 224 - if err != nil { 225 - http.Error(w, "Failed to create request", http.StatusInternalServerError) 226 - return 227 - } 228 - req.Header.Set("Authorization", "Bearer "+serviceToken) 229 - req.Header.Set("Content-Type", "application/json") 230 - req.Header.Set("X-User-DID", user.DID) 231 - 232 - httpClient := &http.Client{} 233 - resp, err := httpClient.Do(req) 234 - if err != nil { 235 - slog.Warn("Failed to call checkout endpoint", "error", err) 68 + slog.Warn("Failed to create checkout session", "did", user.DID, "tier", tier, "error", err) 236 69 http.Error(w, "Failed to create checkout session", http.StatusInternalServerError) 237 - return 238 - } 239 - defer resp.Body.Close() 240 - 241 - if resp.StatusCode != http.StatusOK { 242 - http.Error(w, "Hold returned error", resp.StatusCode) 243 70 return 244 71 } 245 72 246 - var checkoutResp struct { 247 - CheckoutURL string `json:"checkoutUrl"` 248 - } 249 - if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil { 250 - http.Error(w, "Invalid response from hold", http.StatusInternalServerError) 251 - return 252 - } 253 - 254 - // Redirect to Stripe checkout 255 - http.Redirect(w, r, checkoutResp.CheckoutURL, http.StatusFound) 73 + http.Redirect(w, r, resp.CheckoutURL, http.StatusFound) 256 74 } 257 75 258 - // SubscriptionPortalHandler redirects to hold's Stripe billing portal. 76 + // SubscriptionPortalHandler redirects to Stripe billing portal. 259 77 type SubscriptionPortalHandler struct { 260 78 BaseUIHandler 261 79 } ··· 267 85 return 268 86 } 269 87 270 - // Get user's default hold 271 - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 272 - profile, err := storage.GetProfile(r.Context(), client) 273 - if err != nil { 274 - http.Error(w, "Failed to load profile", http.StatusInternalServerError) 88 + if h.BillingManager == nil || !h.BillingManager.Enabled() { 89 + http.Error(w, "Billing not available", http.StatusNotFound) 275 90 return 276 91 } 277 92 278 - holdDID := h.DefaultHoldDID 279 - if profile != nil && profile.DefaultHold != "" { 280 - holdDID = profile.DefaultHold 281 - } 282 - 283 - if holdDID == "" { 284 - http.Error(w, "No default hold configured", http.StatusBadRequest) 285 - return 93 + scheme := "https" 94 + if r.TLS == nil { 95 + scheme = "http" 286 96 } 97 + returnURL := scheme + "://" + h.SiteURL + "/settings#storage" 287 98 288 - // Resolve hold endpoint 289 - holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID) 99 + resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL) 290 100 if err != nil { 291 - slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err) 292 - http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) 293 - return 294 - } 295 - 296 - // Get service token 297 - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 298 - if err != nil { 299 - slog.Warn("Failed to get service token for portal", "did", user.DID, "holdDid", holdDID, "error", err) 300 - http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError) 301 - return 302 - } 303 - 304 - // Call hold's portal endpoint 305 - portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings%%23storage", holdEndpoint, h.SiteURL) 306 - 307 - req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil) 308 - if err != nil { 309 - http.Error(w, "Failed to create request", http.StatusInternalServerError) 310 - return 311 - } 312 - req.Header.Set("Authorization", "Bearer "+serviceToken) 313 - req.Header.Set("X-User-DID", user.DID) 314 - 315 - httpClient := &http.Client{} 316 - resp, err := httpClient.Do(req) 317 - if err != nil { 318 - slog.Warn("Failed to call portal endpoint", "error", err) 101 + slog.Warn("Failed to get billing portal URL", "did", user.DID, "error", err) 319 102 http.Error(w, "Failed to get billing portal", http.StatusInternalServerError) 320 103 return 321 104 } 322 - defer resp.Body.Close() 323 105 324 - if resp.StatusCode != http.StatusOK { 325 - http.Error(w, "Hold returned error", resp.StatusCode) 326 - return 327 - } 328 - 329 - var portalResp struct { 330 - PortalURL string `json:"portalUrl"` 331 - } 332 - if err := json.NewDecoder(resp.Body).Decode(&portalResp); err != nil { 333 - http.Error(w, "Invalid response from hold", http.StatusInternalServerError) 334 - return 335 - } 336 - 337 - // Redirect to Stripe portal 338 - http.Redirect(w, r, portalResp.PortalURL, http.StatusFound) 106 + http.Redirect(w, r, resp.PortalURL, http.StatusFound) 339 107 }
+2 -12
pkg/appview/handlers/user.go
··· 62 62 } 63 63 db.SetRegistryURL(cards, h.RegistryURL) 64 64 65 - // Check for supporter badge on user's default hold 66 - var supporterBadge string 67 - if h.ReadOnlyDB != nil { 68 - holdDID := viewedUser.DefaultHoldDID 69 - if holdDID == "" { 70 - // Fallback: check if user has any crew membership 71 - holdDID = db.GetCrewHoldDID(h.ReadOnlyDB, viewedUser.DID) 72 - } 73 - if holdDID != "" { 74 - supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, holdDID) 75 - } 76 - } 65 + // Check for supporter badge based on billing subscription 66 + supporterBadge := h.BillingManager.GetSupporterBadge(viewedUser.DID) 77 67 78 68 // Build page meta 79 69 meta := NewPageMeta(
+98 -212
pkg/appview/handlers/webhooks.go
··· 1 1 package handlers 2 2 3 3 import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 4 "log/slog" 8 5 "net/http" 6 + "strings" 7 + "time" 9 8 10 9 "atcr.io/pkg/appview/db" 11 10 "atcr.io/pkg/appview/middleware" 12 - "atcr.io/pkg/appview/storage" 13 - "atcr.io/pkg/atproto" 14 - "atcr.io/pkg/auth" 11 + "atcr.io/pkg/appview/webhooks" 15 12 "github.com/go-chi/chi/v5" 13 + "github.com/google/uuid" 16 14 ) 17 15 18 - // webhookListResponse mirrors the hold's listWebhooks response 19 - type webhookListResponse struct { 20 - Webhooks []webhookEntry `json:"webhooks"` 21 - Limits webhookLimits `json:"limits"` 22 - } 23 - 16 + // webhookEntry is the template data for displaying a webhook 24 17 type webhookEntry struct { 25 - Rkey string `json:"rkey"` 26 - Triggers int `json:"triggers"` 27 - URL string `json:"url"` 28 - HasSecret bool `json:"hasSecret"` 29 - CreatedAt string `json:"createdAt"` 18 + ID string 19 + Triggers int 20 + URL string 21 + HasSecret bool 22 + CreatedAt string 30 23 31 - // Computed fields (not from JSON) 24 + // Computed fields from bitmask 32 25 HasFirst bool 33 26 HasAll bool 34 27 HasChanged bool 35 28 } 36 29 37 30 type webhookLimits struct { 38 - Max int `json:"max"` 39 - AllTriggers bool `json:"allTriggers"` 31 + Max int 32 + AllTriggers bool 40 33 } 41 34 42 35 // WebhooksHandler returns the webhooks list partial via HTMX ··· 51 44 return 52 45 } 53 46 54 - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 55 - if err != nil { 56 - h.renderWebhookError(w, "Could not resolve hold: "+err.Error()) 57 - return 58 - } 59 - 60 - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 47 + webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID) 61 48 if err != nil { 62 - h.renderWebhookError(w, "Failed to authenticate with hold") 49 + slog.Warn("Failed to list webhooks", "error", err) 50 + h.renderWebhookError(w, "Failed to load webhooks") 63 51 return 64 52 } 65 53 66 - // Fetch webhooks from hold 67 - listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID) 68 - req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil) 69 - req.Header.Set("Authorization", "Bearer "+serviceToken) 70 - 71 - resp, err := http.DefaultClient.Do(req) 72 - if err != nil { 73 - slog.Warn("Failed to fetch webhooks from hold", "error", err) 74 - h.renderWebhookError(w, "Hold unreachable") 75 - return 76 - } 77 - defer resp.Body.Close() 54 + // Get tier limits from billing manager 55 + maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 78 56 79 - if resp.StatusCode != http.StatusOK { 80 - h.renderWebhookError(w, fmt.Sprintf("Hold returned status %d", resp.StatusCode)) 81 - return 82 - } 83 - 84 - var listResp webhookListResponse 85 - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { 86 - h.renderWebhookError(w, "Invalid response from hold") 87 - return 88 - } 89 - 90 - h.renderWebhookList(w, listResp, holdDID) 57 + h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) 91 58 } 92 59 93 60 // AddWebhookHandler handles adding a new webhook via form POST ··· 109 76 return 110 77 } 111 78 79 + // Validate URL scheme 80 + if !strings.HasPrefix(webhookURL, "https://") && !strings.HasPrefix(webhookURL, "http://") { 81 + h.renderWebhookError(w, "Invalid webhook URL: must be https") 82 + return 83 + } 84 + 112 85 // Parse trigger checkboxes 113 86 triggers := 0 114 87 if r.FormValue("trigger_first") == "on" { 115 - triggers |= atproto.TriggerFirst 88 + triggers |= webhooks.TriggerFirst 116 89 } 117 90 if r.FormValue("trigger_all") == "on" { 118 - triggers |= atproto.TriggerAll 91 + triggers |= webhooks.TriggerAll 119 92 } 120 93 if r.FormValue("trigger_changed") == "on" { 121 - triggers |= atproto.TriggerChanged 94 + triggers |= webhooks.TriggerChanged 122 95 } 123 96 if triggers == 0 { 124 - triggers = atproto.TriggerFirst // default 97 + triggers = webhooks.TriggerFirst // default 125 98 } 126 99 127 - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 128 - if err != nil { 129 - h.renderWebhookError(w, "Could not resolve hold") 130 - return 131 - } 100 + // Tier enforcement 101 + maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 132 102 133 - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 103 + // Check webhook count limit 104 + count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) 134 105 if err != nil { 135 - h.renderWebhookError(w, "Failed to authenticate with hold") 106 + h.renderWebhookError(w, "Failed to check webhook count") 136 107 return 137 108 } 138 - 139 - // Call hold addWebhook 140 - addBody, _ := json.Marshal(map[string]any{ 141 - "url": webhookURL, 142 - "secret": secret, 143 - "triggers": triggers, 144 - }) 145 - 146 - addURL := holdEndpoint + atproto.HoldAddWebhook 147 - req, _ := http.NewRequestWithContext(r.Context(), "POST", addURL, bytes.NewReader(addBody)) 148 - req.Header.Set("Authorization", "Bearer "+serviceToken) 149 - req.Header.Set("Content-Type", "application/json") 150 - 151 - resp, err := http.DefaultClient.Do(req) 152 - if err != nil { 153 - h.renderWebhookError(w, "Hold unreachable") 109 + if maxWebhooks >= 0 && count >= maxWebhooks { 110 + h.renderWebhookError(w, "Webhook limit reached") 154 111 return 155 112 } 156 - defer resp.Body.Close() 157 113 158 - if resp.StatusCode != http.StatusCreated { 159 - var errBody struct { 160 - Message string `json:"message"` 161 - } 162 - body := make([]byte, 512) 163 - n, _ := resp.Body.Read(body) 164 - _ = json.Unmarshal(body[:n], &errBody) 165 - msg := string(body[:n]) 166 - if errBody.Message != "" { 167 - msg = errBody.Message 168 - } 169 - h.renderWebhookError(w, "Failed to add webhook: "+msg) 114 + // Trigger bitmask enforcement: free users can only set TriggerFirst 115 + if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 { 116 + h.renderWebhookError(w, "Additional trigger types require a paid plan") 170 117 return 171 118 } 172 119 173 - var addResp struct { 174 - Rkey string `json:"rkey"` 175 - CID string `json:"cid"` 120 + // Create webhook 121 + webhook := &db.Webhook{ 122 + ID: uuid.New().String(), 123 + UserDID: user.DID, 124 + URL: webhookURL, 125 + Secret: secret, 126 + Triggers: triggers, 127 + CreatedAt: time.Now(), 176 128 } 177 - _ = json.NewDecoder(resp.Body).Decode(&addResp) 178 129 179 - // Write sailor webhook record to user's PDS 180 - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 181 - sailorRecord := atproto.NewSailorWebhookRecord(holdDID, triggers, addResp.CID) 182 - if _, err := client.PutRecord(r.Context(), atproto.SailorWebhookCollection, addResp.Rkey, sailorRecord); err != nil { 183 - slog.Warn("Failed to write sailor webhook record to PDS (hold record exists)", 184 - "did", user.DID, "rkey", addResp.Rkey, "error", err) 185 - // Not fatal — hold has the record, PDS write is best-effort 130 + if err := db.InsertWebhook(h.DB, webhook); err != nil { 131 + slog.Warn("Failed to insert webhook", "error", err) 132 + h.renderWebhookError(w, "Failed to add webhook") 133 + return 186 134 } 187 135 188 136 // Re-render the full list 189 - h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken) 137 + h.refetchAndRender(w, user) 190 138 } 191 139 192 140 // DeleteWebhookHandler handles deleting a webhook ··· 201 149 return 202 150 } 203 151 204 - rkey := chi.URLParam(r, "id") 205 - if rkey == "" { 152 + id := chi.URLParam(r, "id") 153 + if id == "" { 206 154 h.renderWebhookError(w, "Missing webhook ID") 207 155 return 208 156 } 209 157 210 - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 211 - if err != nil { 212 - h.renderWebhookError(w, "Could not resolve hold") 213 - return 214 - } 215 - 216 - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 217 - if err != nil { 218 - h.renderWebhookError(w, "Failed to authenticate with hold") 219 - return 220 - } 221 - 222 - // Call hold deleteWebhook 223 - delBody, _ := json.Marshal(map[string]string{"rkey": rkey}) 224 - delURL := holdEndpoint + atproto.HoldDeleteWebhook 225 - req, _ := http.NewRequestWithContext(r.Context(), "POST", delURL, bytes.NewReader(delBody)) 226 - req.Header.Set("Authorization", "Bearer "+serviceToken) 227 - req.Header.Set("Content-Type", "application/json") 228 - 229 - resp, err := http.DefaultClient.Do(req) 230 - if err != nil { 231 - h.renderWebhookError(w, "Hold unreachable") 232 - return 233 - } 234 - defer resp.Body.Close() 235 - 236 - if resp.StatusCode != http.StatusOK { 237 - h.renderWebhookError(w, "Failed to delete webhook") 158 + if err := db.DeleteWebhook(h.DB, id, user.DID); err != nil { 159 + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not owned") { 160 + h.renderWebhookError(w, "Webhook not found") 161 + } else { 162 + h.renderWebhookError(w, "Failed to delete webhook") 163 + } 238 164 return 239 165 } 240 166 241 - // Delete sailor webhook record from PDS (best-effort) 242 - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 243 - if err := client.DeleteRecord(r.Context(), atproto.SailorWebhookCollection, rkey); err != nil { 244 - slog.Warn("Failed to delete sailor webhook record from PDS", 245 - "did", user.DID, "rkey", rkey, "error", err) 246 - } 247 - 248 167 // Re-render the full list 249 - h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken) 168 + h.refetchAndRender(w, user) 250 169 } 251 170 252 171 // TestWebhookHandler sends a test payload ··· 261 180 return 262 181 } 263 182 264 - rkey := chi.URLParam(r, "id") 265 - if rkey == "" { 183 + id := chi.URLParam(r, "id") 184 + if id == "" { 266 185 h.renderWebhookError(w, "Missing webhook ID") 267 186 return 268 187 } 269 188 270 - holdDID, holdEndpoint, err := h.resolveUserHold(r, user) 271 - if err != nil { 272 - h.renderWebhookError(w, "Could not resolve hold") 189 + if h.WebhookDispatcher == nil { 190 + h.renderAlert(w, "error", "Webhooks not configured") 273 191 return 274 192 } 275 193 276 - serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 194 + success, err := h.WebhookDispatcher.DeliverTest(r.Context(), id, user.DID, user.Handle) 277 195 if err != nil { 278 - h.renderWebhookError(w, "Failed to authenticate with hold") 196 + h.renderAlert(w, "error", "Webhook not found or unauthorized") 279 197 return 280 198 } 281 199 282 - testBody, _ := json.Marshal(map[string]string{"rkey": rkey}) 283 - testURL := holdEndpoint + atproto.HoldTestWebhook 284 - req, _ := http.NewRequestWithContext(r.Context(), "POST", testURL, bytes.NewReader(testBody)) 285 - req.Header.Set("Authorization", "Bearer "+serviceToken) 286 - req.Header.Set("Content-Type", "application/json") 287 - 288 - resp, err := http.DefaultClient.Do(req) 289 - if err != nil { 290 - h.renderAlert(w, "error", "Hold unreachable") 291 - return 292 - } 293 - defer resp.Body.Close() 294 - 295 - var testResp struct { 296 - Success bool `json:"success"` 297 - } 298 - _ = json.NewDecoder(resp.Body).Decode(&testResp) 299 - 300 - if testResp.Success { 200 + if success { 301 201 h.renderAlert(w, "success", "Test webhook delivered successfully!") 302 202 } else { 303 203 h.renderAlert(w, "error", "Test delivery failed - check the webhook URL") ··· 306 206 307 207 // ---- Shared helpers ---- 308 208 309 - func (h *BaseUIHandler) resolveUserHold(r *http.Request, user *db.User) (holdDID, holdEndpoint string, err error) { 310 - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 311 - profile, profileErr := storage.GetProfile(r.Context(), client) 312 - 313 - holdDID = h.DefaultHoldDID 314 - if profileErr == nil && profile != nil && profile.DefaultHold != "" { 315 - holdDID = profile.DefaultHold 316 - } 317 - 318 - if holdDID == "" { 319 - return "", "", fmt.Errorf("no hold configured") 209 + // getWebhookLimits returns the webhook limits for a user based on their billing tier. 210 + func (h *BaseUIHandler) getWebhookLimits(userDID string) (maxWebhooks int, allTriggers bool) { 211 + if h.BillingManager != nil && h.BillingManager.Enabled() { 212 + return h.BillingManager.GetWebhookLimits(userDID) 320 213 } 321 - 322 - holdEndpoint, err = atproto.ResolveHoldURL(r.Context(), holdDID) 323 - if err != nil { 324 - return holdDID, "", fmt.Errorf("failed to resolve hold: %w", err) 325 - } 326 - return holdDID, holdEndpoint, nil 214 + return 1, false 327 215 } 328 216 329 - func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, r *http.Request, user *db.User, holdDID, holdEndpoint, serviceToken string) { 330 - listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID) 331 - req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil) 332 - req.Header.Set("Authorization", "Bearer "+serviceToken) 333 - 334 - resp, err := http.DefaultClient.Do(req) 217 + func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) { 218 + webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID) 335 219 if err != nil { 336 220 h.renderWebhookError(w, "Failed to refresh webhook list") 337 221 return 338 222 } 339 - defer resp.Body.Close() 340 223 341 - var listResp webhookListResponse 342 - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { 343 - h.renderWebhookError(w, "Invalid response from hold") 344 - return 345 - } 346 - 347 - h.renderWebhookList(w, listResp, holdDID) 224 + maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 225 + h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) 348 226 } 349 227 350 - func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, data webhookListResponse, holdDID string) { 228 + func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) { 351 229 w.Header().Set("Content-Type", "text/html") 352 230 353 - // Populate computed trigger fields from bitmask 354 - for i := range data.Webhooks { 355 - data.Webhooks[i].HasFirst = data.Webhooks[i].Triggers&atproto.TriggerFirst != 0 356 - data.Webhooks[i].HasAll = data.Webhooks[i].Triggers&atproto.TriggerAll != 0 357 - data.Webhooks[i].HasChanged = data.Webhooks[i].Triggers&atproto.TriggerChanged != 0 231 + // Convert DB webhooks to template entries with computed trigger fields 232 + entries := make([]webhookEntry, len(dbWebhooks)) 233 + for i, wh := range dbWebhooks { 234 + entries[i] = webhookEntry{ 235 + ID: wh.ID, 236 + Triggers: wh.Triggers, 237 + URL: wh.URL, 238 + HasSecret: wh.HasSecret, 239 + CreatedAt: wh.CreatedAt.Format(time.RFC3339), 240 + HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, 241 + HasAll: wh.Triggers&webhooks.TriggerAll != 0, 242 + HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, 243 + } 358 244 } 359 245 360 246 templateData := struct { 361 247 Webhooks []webhookEntry 362 248 Limits webhookLimits 363 - HoldDID string 249 + ContainerID string 364 250 TriggerInfo []triggerInfo 365 251 }{ 366 - Webhooks: data.Webhooks, 367 - Limits: data.Limits, 368 - HoldDID: holdDID, 252 + Webhooks: entries, 253 + Limits: limits, 254 + ContainerID: "webhooks-content", 369 255 TriggerInfo: []triggerInfo{ 370 - {Name: "scan:first", Bit: atproto.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 371 - {Name: "scan:all", Bit: atproto.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 372 - {Name: "scan:changed", Bit: atproto.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 256 + {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 257 + {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 258 + {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 373 259 }, 374 260 } 375 261
+63
pkg/appview/holdclient/tier_query.go
··· 1 + package holdclient 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "atcr.io/pkg/atproto" 13 + ) 14 + 15 + // HoldTierInfo describes a single tier from a hold's listTiers response. 16 + type HoldTierInfo struct { 17 + Name string `json:"name"` 18 + QuotaBytes int64 `json:"quotaBytes"` 19 + QuotaFormatted string `json:"quotaFormatted"` 20 + ScanOnPush bool `json:"scanOnPush"` 21 + } 22 + 23 + // HoldTiersResponse is the response from a hold's io.atcr.hold.listTiers endpoint. 24 + type HoldTiersResponse struct { 25 + Tiers []HoldTierInfo `json:"tiers"` 26 + } 27 + 28 + // ListTiers queries a hold's public listTiers endpoint to get tier definitions. 29 + // No authentication is required. 30 + func ListTiers(ctx context.Context, holdDID string) (*HoldTiersResponse, error) { 31 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 32 + if err != nil { 33 + return nil, fmt.Errorf("could not resolve hold DID to URL: %w", err) 34 + } 35 + 36 + url := strings.TrimSuffix(holdURL, "/") + atproto.HoldListTiers 37 + 38 + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 39 + defer cancel() 40 + 41 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to create request: %w", err) 44 + } 45 + 46 + resp, err := http.DefaultClient.Do(req) 47 + if err != nil { 48 + return nil, fmt.Errorf("failed to query listTiers on %s: %w", holdDID, err) 49 + } 50 + defer resp.Body.Close() 51 + 52 + if resp.StatusCode != http.StatusOK { 53 + body, _ := io.ReadAll(resp.Body) 54 + return nil, fmt.Errorf("listTiers on %s returned %d: %s", holdDID, resp.StatusCode, string(body)) 55 + } 56 + 57 + var result HoldTiersResponse 58 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 59 + return nil, fmt.Errorf("failed to decode listTiers response from %s: %w", holdDID, err) 60 + } 61 + 62 + return &result, nil 63 + }
+97
pkg/appview/holdclient/tier_update.go
··· 1 + // Package holdclient provides client functions for the appview to call hold XRPC endpoints. 2 + package holdclient 3 + 4 + import ( 5 + "bytes" 6 + "context" 7 + "encoding/json" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "net/http" 12 + "strings" 13 + 14 + "atcr.io/pkg/atproto" 15 + "atcr.io/pkg/auth" 16 + "github.com/bluesky-social/indigo/atproto/atcrypto" 17 + ) 18 + 19 + // UpdateCrewTierOnHold calls io.atcr.hold.updateCrewTier on a specific hold. 20 + // It signs a short-lived JWT with the appview's P-256 key and sends the tier update request. 21 + func UpdateCrewTierOnHold(ctx context.Context, holdDID, holdURL, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) error { 22 + // Sign appview service token 23 + token, err := auth.CreateAppviewServiceToken(privateKey, appviewDID, holdDID, userDID) 24 + if err != nil { 25 + return fmt.Errorf("failed to create appview token: %w", err) 26 + } 27 + 28 + // Build request body 29 + body, err := json.Marshal(map[string]any{ 30 + "userDid": userDID, 31 + "tierRank": tierRank, 32 + }) 33 + if err != nil { 34 + return fmt.Errorf("failed to marshal request: %w", err) 35 + } 36 + 37 + // Build URL 38 + url := strings.TrimSuffix(holdURL, "/") + atproto.HoldUpdateCrewTier 39 + 40 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 41 + if err != nil { 42 + return fmt.Errorf("failed to create request: %w", err) 43 + } 44 + req.Header.Set("Content-Type", "application/json") 45 + req.Header.Set("Authorization", "Bearer "+token) 46 + 47 + resp, err := http.DefaultClient.Do(req) 48 + if err != nil { 49 + return fmt.Errorf("failed to call updateCrewTier on %s: %w", holdDID, err) 50 + } 51 + defer resp.Body.Close() 52 + 53 + if resp.StatusCode != http.StatusOK { 54 + respBody, _ := io.ReadAll(resp.Body) 55 + return fmt.Errorf("updateCrewTier on %s returned %d: %s", holdDID, resp.StatusCode, string(respBody)) 56 + } 57 + 58 + return nil 59 + } 60 + 61 + // urlFromDIDWeb converts a did:web to its HTTPS URL. 62 + func urlFromDIDWeb(did string) string { 63 + if !strings.HasPrefix(did, "did:web:") { 64 + return "" 65 + } 66 + host := strings.TrimPrefix(did, "did:web:") 67 + host = strings.ReplaceAll(host, "%3A", ":") 68 + return "https://" + host 69 + } 70 + 71 + // UpdateCrewTierOnAllHolds pushes a tier update to all managed holds. 72 + // It resolves each hold DID to a URL and calls updateCrewTier. 73 + // Errors are logged but do not cause the function to fail — best effort. 74 + func UpdateCrewTierOnAllHolds(ctx context.Context, managedHolds []string, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) { 75 + for _, holdDID := range managedHolds { 76 + holdURL := urlFromDIDWeb(holdDID) 77 + if holdURL == "" { 78 + slog.Warn("Could not resolve hold DID to URL, skipping", "holdDID", holdDID) 79 + continue 80 + } 81 + 82 + if err := UpdateCrewTierOnHold(ctx, holdDID, holdURL, userDID, tierRank, privateKey, appviewDID); err != nil { 83 + slog.Error("Failed to update crew tier on hold", 84 + "holdDID", holdDID, 85 + "userDID", userDID, 86 + "tierRank", tierRank, 87 + "error", err, 88 + ) 89 + } else { 90 + slog.Info("Updated crew tier on hold", 91 + "holdDID", holdDID, 92 + "userDID", userDID, 93 + "tierRank", tierRank, 94 + ) 95 + } 96 + } 97 + }
+1 -10
pkg/appview/jetstream/backfill.go
··· 85 85 atproto.StatsCollection, // io.atcr.hold.stats (from holds) 86 86 atproto.CaptainCollection, // io.atcr.hold.captain (from holds) 87 87 atproto.CrewCollection, // io.atcr.hold.crew (from holds) 88 + atproto.ScanCollection, // io.atcr.hold.scan (from holds) 88 89 } 89 90 90 91 for _, collection := range collections { ··· 432 433 // Set fields not from JSON 433 434 captainRecord.HoldDID = holdDID 434 435 captainRecord.UpdatedAt = time.Now() 435 - 436 - // Extract supporterBadgeTiers from raw JSON (db struct uses json:"-" so unmarshal skips it) 437 - var raw struct { 438 - SupporterBadgeTiers []string `json:"supporterBadgeTiers"` 439 - } 440 - if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 { 441 - if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil { 442 - captainRecord.SupporterBadgeTiers = string(jsonBytes) 443 - } 444 - } 445 436 446 437 if err := db.UpsertCaptainRecord(b.db, &captainRecord); err != nil { 447 438 return fmt.Errorf("failed to cache captain record: %w", err)
+162 -36
pkg/appview/jetstream/processor.go
··· 10 10 11 11 "atcr.io/pkg/appview/db" 12 12 "atcr.io/pkg/atproto" 13 + atpdata "github.com/bluesky-social/indigo/atproto/atdata" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 14 15 "github.com/bluesky-social/indigo/atproto/lexicon" 15 16 ) ··· 17 18 // Processor handles shared database operations for both Worker (live) and Backfill (sync) 18 19 // This eliminates code duplication between the two data ingestion paths 19 20 type Processor struct { 20 - db db.DBTX 21 - userCache *UserCache // Optional - enabled for Worker, disabled for Backfill 22 - statsCache *StatsCache // In-memory cache for per-hold stats aggregation 23 - useCache bool 24 - catalog *lexicon.ResolvingCatalog // For debug logging of validation failures 21 + db db.DBTX 22 + userCache *UserCache // Optional - enabled for Worker, disabled for Backfill 23 + statsCache *StatsCache // In-memory cache for per-hold stats aggregation 24 + useCache bool 25 + catalog *lexicon.ResolvingCatalog // For debug logging of validation failures 26 + webhookDispatcher WebhookDispatcher // Optional - only for live Worker (nil for Backfill) 27 + } 28 + 29 + // WebhookDispatcher is an interface for dispatching webhooks on scan completion. 30 + // Only the live Worker sets this; backfill does NOT (avoids spamming old scan results). 31 + type WebhookDispatcher interface { 32 + DispatchForScan(ctx context.Context, scan, previousScan *db.Scan, userHandle, tag, holdEndpoint string) 33 + } 34 + 35 + // SetWebhookDispatcher sets the webhook dispatcher for scan processing. 36 + // Only the live Worker should set this — backfill skips webhook dispatch. 37 + func (p *Processor) SetWebhookDispatcher(d WebhookDispatcher) { 38 + p.webhookDispatcher = d 25 39 } 26 40 27 41 // NewProcessor creates a new shared processor ··· 107 121 return db.UpsertUserIgnoreAvatar(p.db, user) 108 122 } 109 123 124 + // EnsureUserExists ensures a user row exists in the database without updating it. 125 + // Used by non-profile collections to avoid unnecessary writes during backfill. 126 + // If the user doesn't exist, resolves identity and inserts with ON CONFLICT DO NOTHING. 127 + func (p *Processor) EnsureUserExists(ctx context.Context, did string) error { 128 + // Check cache first (if enabled) 129 + if p.useCache && p.userCache != nil { 130 + if _, ok := p.userCache.cache[did]; ok { 131 + return nil // User in cache, nothing to do 132 + } 133 + } else if !p.useCache { 134 + // No cache - check if user already exists in DB 135 + existingUser, err := db.GetUserByDID(p.db, did) 136 + if err == nil && existingUser != nil { 137 + return nil // User exists, nothing to do 138 + } 139 + } 140 + 141 + // User doesn't exist yet — resolve and insert 142 + resolvedDID, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did) 143 + if err != nil { 144 + return err 145 + } 146 + 147 + avatarURL := "" 148 + client := atproto.NewClient(pdsEndpoint, "", "") 149 + profileRecord, err := client.GetProfileRecord(ctx, resolvedDID) 150 + if err != nil { 151 + slog.Warn("Failed to fetch profile record", "component", "processor", "did", resolvedDID, "error", err) 152 + } else if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 153 + avatarURL = atproto.BlobCDNURL(resolvedDID, profileRecord.Avatar.Ref.Link) 154 + } 155 + 156 + user := &db.User{ 157 + DID: resolvedDID, 158 + Handle: handle, 159 + PDSEndpoint: pdsEndpoint, 160 + Avatar: avatarURL, 161 + LastSeen: time.Now(), 162 + } 163 + 164 + // Cache if enabled 165 + if p.useCache { 166 + p.userCache.cache[did] = user 167 + } 168 + 169 + return db.InsertUserIfNotExists(p.db, user) 170 + } 171 + 110 172 // ValidateRecord performs validation on records. 111 173 // - Full lexicon validation is logged for debugging but does NOT block ingestion 112 174 // - Targeted validation (captain/crew DID checks) DOES block bogus records 113 175 func (p *Processor) ValidateRecord(ctx context.Context, collection string, data []byte) error { 114 - var recordData map[string]any 115 - if err := json.Unmarshal(data, &recordData); err != nil { 176 + recordData, err := atpdata.UnmarshalJSON(data) 177 + if err != nil { 116 178 return fmt.Errorf("invalid JSON: %w", err) 117 179 } 118 180 ··· 171 233 // Skip for deletes - user should already exist, and we don't need to resolve identity 172 234 if !isDelete { 173 235 switch collection { 236 + case atproto.SailorProfileCollection: 237 + // Sailor profile is the authoritative source for user data — full upsert 238 + if err := p.EnsureUser(ctx, did); err != nil { 239 + return fmt.Errorf("failed to ensure user: %w", err) 240 + } 174 241 case atproto.ManifestCollection, 175 242 atproto.TagCollection, 176 243 atproto.StarCollection, 177 - atproto.RepoPageCollection, 178 - atproto.SailorProfileCollection: 179 - if err := p.EnsureUser(ctx, did); err != nil { 180 - return fmt.Errorf("failed to ensure user: %w", err) 244 + atproto.RepoPageCollection: 245 + // Other user collections just need the row to exist — no update if unchanged 246 + if err := p.EnsureUserExists(ctx, did); err != nil { 247 + return fmt.Errorf("failed to ensure user exists: %w", err) 181 248 } 182 249 // Hold collections (captain, crew, stats) - don't create user entries 183 250 // These are records FROM holds, not user activity ··· 215 282 216 283 case atproto.SailorProfileCollection: 217 284 return p.ProcessSailorProfile(ctx, did, data, queryCaptainFn) 285 + 286 + case atproto.ScanCollection: 287 + return p.ProcessScan(ctx, did, data, isDelete) 218 288 219 289 case atproto.StatsCollection: 220 290 return p.ProcessStats(ctx, did, data, isDelete) ··· 424 494 // Ensure the starred repository's owner exists in the users table 425 495 // (the starrer is already ensured by ProcessRecord, but the owner 426 496 // may not have been processed yet during backfill or live events) 427 - if err := p.EnsureUser(ctx, ownerDID); err != nil { 497 + if err := p.EnsureUserExists(ctx, ownerDID); err != nil { 428 498 return fmt.Errorf("failed to ensure star subject user: %w", err) 429 499 } 430 500 ··· 615 685 return nil 616 686 } 617 687 688 + // ProcessScan handles scan record events from hold PDSes. 689 + // Caches scan results in the appview DB and dispatches webhooks (if dispatcher is set). 690 + func (p *Processor) ProcessScan(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error { 691 + if isDelete { 692 + return nil // Scan deletes are not processed (scans are immutable) 693 + } 694 + 695 + // Unmarshal scan record 696 + var scanRecord atproto.ScanRecord 697 + if err := json.Unmarshal(recordData, &scanRecord); err != nil { 698 + return fmt.Errorf("failed to unmarshal scan record: %w", err) 699 + } 700 + 701 + // Extract manifest digest from the scan record's manifest AT-URI 702 + manifestDigest := "" 703 + if parts := strings.Split(scanRecord.Manifest, "/"); len(parts) > 0 { 704 + manifestDigest = "sha256:" + parts[len(parts)-1] 705 + } 706 + 707 + // Parse scanned_at timestamp 708 + scannedAt := time.Now() 709 + if t, err := time.Parse(time.RFC3339, scanRecord.ScannedAt); err == nil { 710 + scannedAt = t 711 + } 712 + 713 + scan := &db.Scan{ 714 + HoldDID: holdDID, 715 + ManifestDigest: manifestDigest, 716 + UserDID: scanRecord.UserDID, 717 + Repository: scanRecord.Repository, 718 + Critical: int(scanRecord.Critical), 719 + High: int(scanRecord.High), 720 + Medium: int(scanRecord.Medium), 721 + Low: int(scanRecord.Low), 722 + Total: int(scanRecord.Total), 723 + ScannerVersion: scanRecord.ScannerVersion, 724 + ScannedAt: scannedAt, 725 + } 726 + 727 + // Upsert scan to DB (returns previous scan for change detection) 728 + previousScan, err := db.UpsertScan(p.db, scan) 729 + if err != nil { 730 + return fmt.Errorf("failed to upsert scan: %w", err) 731 + } 732 + 733 + // Dispatch webhooks if dispatcher is set (live Worker only, not backfill) 734 + if p.webhookDispatcher != nil { 735 + // Resolve user handle from cache or DB 736 + userHandle := "" 737 + user, userErr := db.GetUserByDID(p.db, scanRecord.UserDID) 738 + if userErr == nil && user != nil { 739 + userHandle = user.Handle 740 + } 741 + 742 + // Resolve tag for the manifest digest 743 + tag := "" 744 + if tagVal, tagErr := db.GetTagByDigest(p.db, scanRecord.UserDID, scanRecord.Repository, manifestDigest); tagErr == nil { 745 + tag = tagVal 746 + } 747 + 748 + // Resolve hold endpoint URL 749 + holdEndpoint := "" 750 + if holdURL, holdErr := atproto.ResolveHoldURL(ctx, holdDID); holdErr == nil { 751 + holdEndpoint = holdURL 752 + } 753 + 754 + p.webhookDispatcher.DispatchForScan(ctx, scan, previousScan, userHandle, tag, holdEndpoint) 755 + } 756 + 757 + return nil 758 + } 759 + 618 760 // ProcessStats handles stats record events from hold PDSes 619 761 // This is called when Jetstream receives a stats create/update/delete event from a hold 620 762 // The holdDID is the DID of the hold PDS (event.DID), and the record contains ownerDID + repository ··· 677 819 var captainRecord atproto.CaptainRecord 678 820 if err := json.Unmarshal(recordData, &captainRecord); err != nil { 679 821 return fmt.Errorf("failed to unmarshal captain record: %w", err) 680 - } 681 - 682 - // Marshal supporter badge tiers to JSON string for storage 683 - badgeTiersJSON := "" 684 - if len(captainRecord.SupporterBadgeTiers) > 0 { 685 - if jsonBytes, err := json.Marshal(captainRecord.SupporterBadgeTiers); err == nil { 686 - badgeTiersJSON = string(jsonBytes) 687 - } 688 822 } 689 823 690 824 // Convert to db struct and upsert 691 825 record := &db.HoldCaptainRecord{ 692 - HoldDID: holdDID, 693 - OwnerDID: captainRecord.Owner, 694 - Public: captainRecord.Public, 695 - AllowAllCrew: captainRecord.AllowAllCrew, 696 - DeployedAt: captainRecord.DeployedAt, 697 - Region: captainRecord.Region, 698 - Successor: captainRecord.Successor, 699 - SupporterBadgeTiers: badgeTiersJSON, 700 - UpdatedAt: time.Now(), 826 + HoldDID: holdDID, 827 + OwnerDID: captainRecord.Owner, 828 + Public: captainRecord.Public, 829 + AllowAllCrew: captainRecord.AllowAllCrew, 830 + DeployedAt: captainRecord.DeployedAt, 831 + Region: captainRecord.Region, 832 + Successor: captainRecord.Successor, 833 + UpdatedAt: time.Now(), 701 834 } 702 835 703 836 if err := db.UpsertCaptainRecord(p.db, record); err != nil { ··· 746 879 if err := db.UpsertCrewMember(p.db, member); err != nil { 747 880 return fmt.Errorf("failed to upsert crew member: %w", err) 748 881 } 749 - 750 - slog.Debug("Processed crew record", 751 - "component", "processor", 752 - "hold_did", holdDID, 753 - "member_did", crewRecord.Member, 754 - "role", crewRecord.Role, 755 - "permissions", crewRecord.Permissions) 756 882 757 883 return nil 758 884 }
+6
pkg/appview/jetstream/worker.go
··· 353 353 w.eventCallback = cb 354 354 } 355 355 356 + // Processor returns the worker's processor for configuration (e.g., setting webhook dispatcher) 357 + func (w *Worker) Processor() *Processor { 358 + return w.processor 359 + } 360 + 356 361 // GetLastCursor returns the last processed cursor (time_us) for reconnects 357 362 func (w *Worker) GetLastCursor() int64 { 358 363 w.cursorMutex.RLock() ··· 482 487 atproto.StatsCollection, 483 488 atproto.CaptainCollection, 484 489 atproto.CrewCollection, 490 + atproto.ScanCollection, 485 491 BlueskyProfileCollection: // For avatar sync 486 492 return true 487 493 default:
+1 -1
pkg/appview/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 - <symbol id="badge-check" viewBox="0 0 24 24"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol> 10 9 <symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol> 11 10 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 12 11 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> ··· 19 18 <symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol> 20 19 <symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol> 21 20 <symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol> 21 + <symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol> 22 22 <symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol> 23 23 <symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol> 24 24 <symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
+39 -34
pkg/appview/routes/routes.go
··· 11 11 "atcr.io/pkg/appview/holdhealth" 12 12 "atcr.io/pkg/appview/middleware" 13 13 "atcr.io/pkg/appview/readme" 14 + "atcr.io/pkg/appview/webhooks" 14 15 "atcr.io/pkg/auth/oauth" 16 + "atcr.io/pkg/billing" 15 17 indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 18 "github.com/go-chi/chi/v5" 17 19 ) ··· 24 26 25 27 // UIDependencies contains all dependencies needed for UI route registration 26 28 type UIDependencies struct { 27 - Database *sql.DB 28 - ReadOnlyDB *sql.DB 29 - SessionStore *db.SessionStore 30 - OAuthClientApp *indigooauth.ClientApp 31 - OAuthStore *db.OAuthStore 32 - Refresher *oauth.Refresher 33 - BaseURL string 34 - RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL 35 - DeviceStore *db.DeviceStore 36 - HealthChecker *holdhealth.Checker 37 - ReadmeFetcher *readme.Fetcher 38 - Templates *template.Template 39 - DefaultHoldDID string 40 - LegalConfig LegalConfig 41 - ClientName string // Full name: "AT Container Registry" 42 - ClientShortName string // Short name: "ATCR" 29 + Database *sql.DB 30 + ReadOnlyDB *sql.DB 31 + SessionStore *db.SessionStore 32 + OAuthClientApp *indigooauth.ClientApp 33 + OAuthStore *db.OAuthStore 34 + Refresher *oauth.Refresher 35 + BaseURL string 36 + RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL 37 + DeviceStore *db.DeviceStore 38 + HealthChecker *holdhealth.Checker 39 + ReadmeFetcher *readme.Fetcher 40 + Templates *template.Template 41 + DefaultHoldDID string 42 + LegalConfig LegalConfig 43 + ClientName string // Full name: "AT Container Registry" 44 + ClientShortName string // Short name: "ATCR" 45 + BillingManager *billing.Manager // Stripe billing manager (nil if not configured) 46 + WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured) 43 47 } 44 48 45 49 // RegisterUIRoutes registers all web UI and API routes on the provided router ··· 55 59 56 60 // Create base with all dependencies - handlers just embed this 57 61 base := uihandlers.BaseUIHandler{ 58 - Templates: deps.Templates, 59 - RegistryURL: registryURL, 60 - SiteURL: siteURL, 61 - DB: deps.Database, 62 - ReadOnlyDB: deps.ReadOnlyDB, 63 - Refresher: deps.Refresher, 64 - HealthChecker: deps.HealthChecker, 65 - ReadmeFetcher: deps.ReadmeFetcher, 66 - Directory: deps.OAuthClientApp.Dir, 67 - SessionStore: deps.SessionStore, 68 - DeviceStore: deps.DeviceStore, 69 - OAuthStore: deps.OAuthStore, 70 - DefaultHoldDID: deps.DefaultHoldDID, 71 - CompanyName: deps.LegalConfig.CompanyName, 72 - Jurisdiction: deps.LegalConfig.Jurisdiction, 73 - ClientName: deps.ClientName, 74 - ClientShortName: deps.ClientShortName, 62 + Templates: deps.Templates, 63 + RegistryURL: registryURL, 64 + SiteURL: siteURL, 65 + DB: deps.Database, 66 + ReadOnlyDB: deps.ReadOnlyDB, 67 + Refresher: deps.Refresher, 68 + HealthChecker: deps.HealthChecker, 69 + ReadmeFetcher: deps.ReadmeFetcher, 70 + Directory: deps.OAuthClientApp.Dir, 71 + SessionStore: deps.SessionStore, 72 + DeviceStore: deps.DeviceStore, 73 + OAuthStore: deps.OAuthStore, 74 + BillingManager: deps.BillingManager, 75 + WebhookDispatcher: deps.WebhookDispatcher, 76 + DefaultHoldDID: deps.DefaultHoldDID, 77 + CompanyName: deps.LegalConfig.CompanyName, 78 + Jurisdiction: deps.LegalConfig.Jurisdiction, 79 + ClientName: deps.ClientName, 80 + ClientShortName: deps.ClientShortName, 75 81 } 76 82 77 83 // OAuth login routes (public) ··· 154 160 r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) 155 161 156 162 // Subscription management 157 - r.Get("/api/subscription", (&uihandlers.SubscriptionHandler{BaseUIHandler: base}).ServeHTTP) 158 163 r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP) 159 164 r.Get("/settings/subscription/portal", (&uihandlers.SubscriptionPortalHandler{BaseUIHandler: base}).ServeHTTP) 160 165
+173 -29
pkg/appview/server.go
··· 28 28 "atcr.io/pkg/appview/readme" 29 29 "atcr.io/pkg/appview/routes" 30 30 "atcr.io/pkg/appview/storage" 31 + "atcr.io/pkg/appview/webhooks" 31 32 "atcr.io/pkg/atproto" 32 33 "atcr.io/pkg/auth" 33 34 "atcr.io/pkg/auth/oauth" 34 35 "atcr.io/pkg/auth/token" 36 + "atcr.io/pkg/billing" 35 37 "atcr.io/pkg/logging" 36 38 39 + "github.com/bluesky-social/indigo/atproto/atcrypto" 37 40 indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 38 41 ) 39 42 ··· 94 97 95 98 // HoldAuthorizer checks hold access permissions. 96 99 HoldAuthorizer auth.HoldAuthorizer 100 + 101 + // OAuthKey is the P-256 private key used for OAuth client auth and appview service identity. 102 + OAuthKey *atcrypto.PrivateKeyP256 103 + 104 + // BillingManager handles Stripe billing and tier updates (nil if billing disabled). 105 + BillingManager *billing.Manager 106 + 107 + // WebhookDispatcher dispatches scan webhooks (stored in appview DB). 108 + WebhookDispatcher *webhooks.Dispatcher 97 109 98 110 // Private fields for lifecycle management 99 111 oauthHooks []OAuthPostAuthHook ··· 191 203 if err != nil { 192 204 return nil, fmt.Errorf("failed to load OAuth key: %w", err) 193 205 } 206 + s.OAuthKey = oauthKey 194 207 195 208 // Create OAuth client app 196 209 desiredScopes := oauth.GetDefaultScopes(defaultHoldDID) ··· 236 249 }() 237 250 } 238 251 252 + // Initialize billing manager 253 + appviewDID := DIDFromBaseURL(baseURL) 254 + s.BillingManager = billing.New( 255 + &cfg.Billing, 256 + oauthKey, 257 + appviewDID, 258 + cfg.Server.ManagedHolds, 259 + baseURL, 260 + ) 261 + // Allow hold captains to bypass billing feature gates 262 + if len(cfg.Server.ManagedHolds) > 0 { 263 + managedHolds := cfg.Server.ManagedHolds 264 + roDB := s.ReadOnlyDB 265 + s.BillingManager.SetCaptainChecker(func(userDID string) bool { 266 + isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds) 267 + return isCaptain 268 + }) 269 + } 270 + if s.BillingManager.Enabled() { 271 + slog.Info("Billing enabled", "appview_did", appviewDID, "managed_holds", len(cfg.Server.ManagedHolds)) 272 + go s.BillingManager.RefreshHoldTiers() 273 + } 274 + 275 + // Create webhook dispatcher 276 + appviewMeta := atproto.AppviewMetadata{ 277 + ClientName: cfg.Server.ClientName, 278 + ClientShortName: cfg.Server.ClientShortName, 279 + BaseURL: cfg.Server.BaseURL, 280 + FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png", 281 + RegistryDomains: cfg.Server.RegistryDomains, 282 + } 283 + s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta) 284 + 239 285 // Initialize Jetstream workers 240 286 s.initializeJetstream() 241 287 ··· 264 310 265 311 // Register UI routes 266 312 routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{ 267 - Database: s.Database, 268 - ReadOnlyDB: s.ReadOnlyDB, 269 - SessionStore: s.SessionStore, 270 - OAuthClientApp: s.OAuthClientApp, 271 - OAuthStore: s.OAuthStore, 272 - Refresher: s.Refresher, 273 - BaseURL: baseURL, 274 - RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains), 275 - DeviceStore: s.DeviceStore, 276 - HealthChecker: s.HealthChecker, 277 - ReadmeFetcher: s.ReadmeFetcher, 278 - Templates: s.Templates, 279 - DefaultHoldDID: defaultHoldDID, 280 - ClientName: cfg.Server.ClientName, 281 - ClientShortName: cfg.Server.ClientShortName, 313 + Database: s.Database, 314 + ReadOnlyDB: s.ReadOnlyDB, 315 + SessionStore: s.SessionStore, 316 + OAuthClientApp: s.OAuthClientApp, 317 + OAuthStore: s.OAuthStore, 318 + Refresher: s.Refresher, 319 + BaseURL: baseURL, 320 + RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains), 321 + DeviceStore: s.DeviceStore, 322 + HealthChecker: s.HealthChecker, 323 + ReadmeFetcher: s.ReadmeFetcher, 324 + Templates: s.Templates, 325 + DefaultHoldDID: defaultHoldDID, 326 + ClientName: cfg.Server.ClientName, 327 + ClientShortName: cfg.Server.ClientShortName, 328 + BillingManager: s.BillingManager, 329 + WebhookDispatcher: s.WebhookDispatcher, 282 330 LegalConfig: routes.LegalConfig{ 283 331 CompanyName: cfg.Legal.CompanyName, 284 332 Jurisdiction: cfg.Legal.Jurisdiction, 285 333 }, 286 334 }) 335 + 336 + // Register Stripe webhook route (if billing enabled) 337 + s.BillingManager.RegisterRoutes(mainRouter) 287 338 288 339 // Create OAuth server 289 340 s.OAuthServer = oauth.NewServer(s.OAuthClientApp) ··· 550 601 } 551 602 }) 552 603 604 + // Appview DID document endpoint (service identity for key discovery) 605 + mainRouter.Get("/.well-known/did.json", s.handleDIDDocument) 606 + 553 607 // Register credential helper version API (public endpoint) 554 608 routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo) 555 609 ··· 692 746 return "" 693 747 } 694 748 749 + // DID returns the appview's did:web identity derived from its BaseURL. 750 + func (s *AppViewServer) DID() string { 751 + return DIDFromBaseURL(s.Config.Server.BaseURL) 752 + } 753 + 754 + // DIDFromBaseURL derives a did:web identifier from a base URL. 755 + // Per the did:web spec, non-standard ports are percent-encoded. 756 + // Examples: 757 + // 758 + // "https://atcr.io" → "did:web:atcr.io" 759 + // "http://localhost:5000" → "did:web:localhost%3A5000" 760 + func DIDFromBaseURL(baseURL string) string { 761 + u, err := url.Parse(baseURL) 762 + if err != nil { 763 + return "did:web:localhost" 764 + } 765 + 766 + hostname := u.Hostname() 767 + if hostname == "" { 768 + hostname = "localhost" 769 + } 770 + 771 + port := u.Port() 772 + isStandardPort := (u.Scheme == "https" && port == "443") || 773 + (u.Scheme == "http" && port == "80") || 774 + port == "" 775 + 776 + if isStandardPort { 777 + return "did:web:" + hostname 778 + } 779 + return fmt.Sprintf("did:web:%s%%3A%s", hostname, port) 780 + } 781 + 782 + // handleDIDDocument serves the appview's DID document at /.well-known/did.json. 783 + // This is a service identity for key discovery — no PDS, no repo, no firehose. 784 + // Holds use this to discover the appview's P-256 public key for JWT verification. 785 + func (s *AppViewServer) handleDIDDocument(w http.ResponseWriter, r *http.Request) { 786 + did := s.DID() 787 + 788 + pubKey, err := s.OAuthKey.PublicKey() 789 + if err != nil { 790 + slog.Error("Failed to get public key for DID document", "error", err) 791 + http.Error(w, "internal error", http.StatusInternalServerError) 792 + return 793 + } 794 + 795 + doc := map[string]any{ 796 + "@context": []string{ 797 + "https://www.w3.org/ns/did/v1", 798 + "https://w3id.org/security/multikey/v1", 799 + }, 800 + "id": did, 801 + "verificationMethod": []map[string]any{ 802 + { 803 + "id": did + "#appview", 804 + "type": "Multikey", 805 + "controller": did, 806 + "publicKeyMultibase": pubKey.Multibase(), 807 + }, 808 + }, 809 + "authentication": []string{ 810 + did + "#appview", 811 + }, 812 + "assertionMethod": []string{ 813 + did + "#appview", 814 + }, 815 + "service": []map[string]any{ 816 + { 817 + "id": "#atcr_appview", 818 + "type": "AtcrAppView", 819 + "serviceEndpoint": s.Config.Server.BaseURL, 820 + }, 821 + }, 822 + } 823 + 824 + w.Header().Set("Content-Type", "application/did+ld+json") 825 + w.Header().Set("Cache-Control", "public, max-age=3600") 826 + w.Header().Set("Access-Control-Allow-Origin", "*") 827 + if err := json.NewEncoder(w).Encode(doc); err != nil { 828 + slog.Error("Failed to encode DID document", "error", err) 829 + } 830 + } 831 + 695 832 // initializeJetstream initializes the Jetstream workers for real-time events and backfill. 696 833 func (s *AppViewServer) initializeJetstream() { 697 834 jetstreamURLs := s.Config.Jetstream.URLs 698 835 699 836 go func() { 700 837 worker := jetstream.NewWorker(s.Database, jetstreamURLs, 0) 838 + // Set webhook dispatcher on live worker (backfill skips dispatch) 839 + if s.WebhookDispatcher != nil { 840 + worker.Processor().SetWebhookDispatcher(s.WebhookDispatcher) 841 + } 701 842 worker.StartWithFailover(context.Background()) 702 843 }() 703 844 slog.Info("Jetstream real-time worker started", "component", "jetstream", "endpoints", len(jetstreamURLs)) ··· 724 865 } 725 866 }() 726 867 727 - interval := 1 * time.Hour 728 - 729 - go func() { 730 - ticker := time.NewTicker(interval) 731 - defer ticker.Stop() 868 + interval := s.Config.Jetstream.BackfillInterval 869 + if interval > 0 { 870 + go func() { 871 + ticker := time.NewTicker(interval) 872 + defer ticker.Stop() 732 873 733 - for range ticker.C { 734 - slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval) 735 - if err := backfillWorker.Start(context.Background()); err != nil { 736 - slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err) 737 - } else { 738 - slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill") 874 + for range ticker.C { 875 + slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval) 876 + if err := backfillWorker.Start(context.Background()); err != nil { 877 + slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err) 878 + } else { 879 + slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill") 880 + } 739 881 } 740 - } 741 - }() 742 - slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval) 882 + }() 883 + slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval) 884 + } else { 885 + slog.Info("Periodic backfill disabled (interval=0), only startup backfill will run", "component", "jetstream/backfill") 886 + } 743 887 } 744 888 } 745 889 }
+2 -2
pkg/appview/src/js/app.js
··· 734 734 } 735 735 736 736 // Test webhook via fetch + toast 737 - async function testWebhook(rkey) { 737 + async function testWebhook(id) { 738 738 try { 739 - const resp = await fetch(`/api/webhooks/${rkey}/test`, { 739 + const resp = await fetch(`/api/webhooks/${id}/test`, { 740 740 method: 'POST', 741 741 credentials: 'include', 742 742 });
+47 -215
pkg/appview/templates/pages/settings.html
··· 9 9 {{ template "nav" . }} 10 10 11 11 <main class="container mx-auto px-4 py-8"> 12 - <div class="max-w-5xl mx-auto"> 13 12 <h1 class="text-3xl font-bold mb-6">Settings</h1> 14 13 14 + <!-- Mobile identity info (below lg) --> 15 + <div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/50"> 16 + <div class="break-all"><code>{{ .Profile.DID }}</code></div> 17 + <div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div> 18 + </div> 19 + 15 20 <!-- Mobile tab bar (below lg) --> 16 21 <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6"> 17 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="identity"> 18 - {{ icon "fingerprint" "size-4" }} Identity 22 + <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage"> 23 + {{ icon "hard-drive" "size-4" }} Storage 19 24 </button> 20 25 <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices"> 21 26 {{ icon "terminal" "size-4" }} Devices 22 - </button> 23 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage"> 24 - {{ icon "hard-drive" "size-4" }} Storage 25 27 </button> 26 28 <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks"> 27 29 {{ icon "webhook" "size-4" }} Webhooks ··· 35 37 <!-- Sidebar (lg and above) --> 36 38 <aside class="hidden lg:block w-56 shrink-0"> 37 39 <ul class="menu bg-base-200 rounded-box w-full"> 38 - <li data-tab="identity"><a href="#identity">{{ icon "fingerprint" "size-4" }} Identity</a></li> 40 + <li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li> 39 41 <li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li> 40 - <li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li> 41 42 <li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li> 42 43 <li data-tab="advanced"><a href="#advanced">{{ icon "shield-check" "size-4" }} Advanced</a></li> 43 44 </ul> 45 + <div class="mt-4 px-2 space-y-1 text-xs text-base-content/50"> 46 + <div class="break-all"><code>{{ .Profile.DID }}</code></div> 47 + <div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div> 48 + </div> 44 49 </aside> 45 50 46 51 <!-- Tab content --> 47 52 <div class="flex-1 min-w-0"> 48 53 49 - <!-- IDENTITY TAB --> 50 - <div id="tab-identity" class="settings-panel space-y-6"> 51 - <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 52 - <h2 class="text-xl font-semibold">Identity</h2> 53 - <div class="grid gap-3"> 54 - <div class="flex flex-col gap-1"> 55 - <span class="text-sm font-medium text-base-content/70">Handle</span> 56 - <span>{{ .Profile.Handle }}</span> 57 - </div> 58 - <div class="flex flex-col gap-1"> 59 - <span class="text-sm font-medium text-base-content/70">DID</span> 60 - <code class="cmd">{{ .Profile.DID }}</code> 61 - </div> 62 - <div class="flex flex-col gap-1"> 63 - <span class="text-sm font-medium text-base-content/70">PDS</span> 64 - <span>{{ .Profile.PDSEndpoint }}</span> 54 + <!-- STORAGE TAB --> 55 + <div id="tab-storage" class="settings-panel space-y-4"> 56 + <!-- Available Plans --> 57 + {{ template "subscription_plans" .Subscription }} 58 + 59 + <!-- Holds --> 60 + {{ if .AllHolds }} 61 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> 62 + <div class="space-y-4"> 63 + {{ template "hold_selector" . }} 64 + {{ if .ActiveHold }} 65 + {{ template "hold_card" .ActiveHold }} 66 + {{ else }} 67 + <div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60"> 68 + No active hold selected. Choose one above. 65 69 </div> 70 + {{ end }} 66 71 </div> 67 - </section> 72 + <div> 73 + {{ if .OtherHolds }} 74 + {{ template "other_holds_table" .OtherHolds }} 75 + {{ end }} 76 + </div> 77 + </div> 78 + {{ else }} 79 + <div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60"> 80 + No holds configured. Push an image to get started. 81 + </div> 82 + {{ end }} 68 83 </div> 69 84 70 85 <!-- DEVICES TAB --> ··· 127 142 </section> 128 143 </div> 129 144 130 - <!-- STORAGE TAB --> 131 - <div id="tab-storage" class="settings-panel hidden space-y-6"> 132 - <!-- Default Hold Section --> 133 - <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 134 - <h2 class="text-xl font-semibold">Default Hold</h2> 135 - <p class="text-base-content/70">Select where your container images will be stored.</p> 136 - 137 - <form hx-post="/api/profile/default-hold" 138 - hx-target="#hold-status" 139 - hx-swap="innerHTML" 140 - id="hold-form" 141 - class="space-y-4"> 142 - 143 - <fieldset class="fieldset"> 144 - <legend class="sr-only">Storage hold selection</legend> 145 - <label class="label" for="default-hold"> 146 - <span class="label-text">Storage Hold</span> 147 - </label> 148 - <select id="default-hold" name="hold_did" class="select select-bordered w-full" autocomplete="off"> 149 - <option value="{{ .AppViewDefaultHoldDID }}"{{ if or (eq .CurrentHoldDID "") (eq .CurrentHoldDID .AppViewDefaultHoldDID) }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option> 150 - 151 - {{ if .ShowCurrentHold }} 152 - <option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option> 153 - {{ end }} 154 - 155 - {{ if .OwnedHolds }} 156 - <optgroup label="Your Holds"> 157 - {{ range .OwnedHolds }} 158 - <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}> 159 - {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }} 160 - </option> 161 - {{ end }} 162 - </optgroup> 163 - {{ end }} 164 - 165 - {{ if .CrewHolds }} 166 - <optgroup label="Crew Member"> 167 - {{ range .CrewHolds }} 168 - <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}> 169 - {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }} 170 - </option> 171 - {{ end }} 172 - </optgroup> 173 - {{ end }} 174 - 175 - {{ if .EligibleHolds }} 176 - <optgroup label="Open Registration"> 177 - {{ range .EligibleHolds }} 178 - <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}> 179 - {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }} 180 - </option> 181 - {{ end }} 182 - </optgroup> 183 - {{ end }} 184 - 185 - </select> 186 - <p class="text-sm text-base-content/60 mt-1">Your images will be stored on the selected hold</p> 187 - </fieldset> 188 - 189 - <button type="submit" class="btn btn-primary">Save</button> 190 - </form> 191 - 192 - <div id="hold-status"></div> 193 - 194 - <!-- Hold details panel (shows when hold selected) --> 195 - <div id="hold-details" class="hidden mt-4 p-4 bg-base-200 rounded-lg"> 196 - <h3 class="font-semibold mb-3">Hold Details</h3> 197 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm"> 198 - <dt class="text-base-content/70">DID:</dt> 199 - <dd id="hold-did" class="font-mono"></dd> 200 - <dt class="text-base-content/70">Region:</dt> 201 - <dd id="hold-region"></dd> 202 - <dt class="text-base-content/70">Status:</dt> 203 - <dd id="hold-status-badge"></dd> 204 - <dt class="text-base-content/70">Your Access:</dt> 205 - <dd id="hold-access"></dd> 206 - </dl> 207 - </div> 208 - </section> 209 - 210 - <!-- Storage Usage Section --> 211 - <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 212 - <h2 class="text-xl font-semibold">Stowage</h2> 213 - <p class="text-base-content/70">Estimated storage usage on your default hold.</p> 214 - <div id="storage-stats" hx-get="/api/storage" hx-trigger="tab:storage from:body once" hx-swap="innerHTML"> 215 - <p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading...</p> 216 - </div> 217 - </section> 218 - 219 - <!-- Subscription Section --> 220 - <div id="subscription-wrapper" hx-get="/api/subscription" hx-trigger="tab:storage from:body once" hx-swap="innerHTML"> 221 - <section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4"> 222 - <h2 class="text-xl font-semibold">Subscription</h2> 223 - <p class="text-base-content/70">Manage your storage tier and billing.</p> 224 - <p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...</p> 225 - </section> 226 - </div> 227 - </div> 228 - 229 145 <!-- WEBHOOKS TAB --> 230 146 <div id="tab-webhooks" class="settings-panel hidden space-y-6"> 231 147 <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 232 148 <div> 233 149 <h2 class="text-xl font-semibold">Scan Webhooks</h2> 234 - <p class="text-base-content/70 mt-1">Get HTTP notifications when vulnerability scans complete.</p> 150 + <p class="text-base-content/70 mt-1">Get notified when vulnerability scans complete on any of your images.</p> 235 151 </div> 236 - <div id="webhooks-content" 237 - hx-get="/api/webhooks" 238 - hx-trigger="tab:webhooks from:body once" 239 - hx-swap="innerHTML"> 240 - <p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading webhooks...</p> 152 + <div id="webhooks-content"> 153 + {{ template "webhooks_list" .WebhooksData }} 241 154 </div> 242 155 </section> 243 156 </div> ··· 302 215 303 216 </div> 304 217 </div> 305 - </div> 306 218 </main> 307 219 308 220 <script> 309 - // Hold data from server (for details panel) 310 - const holdData = {{ .HoldDataJSON }}; 311 - 312 221 // Tab switching 313 222 (function() { 314 - var validTabs = ['identity', 'devices', 'storage', 'webhooks', 'advanced']; 223 + var validTabs = ['storage', 'devices', 'webhooks', 'advanced']; 315 224 316 225 function switchSettingsTab(tabId) { 317 226 // Hide all panels ··· 356 265 357 266 document.addEventListener('DOMContentLoaded', function() { 358 267 // Read initial tab from hash 359 - var hash = window.location.hash.replace('#', '') || 'identity'; 360 - if (validTabs.indexOf(hash) === -1) hash = 'identity'; 268 + var hash = window.location.hash.replace('#', '') || 'storage'; 269 + if (validTabs.indexOf(hash) === -1) hash = 'storage'; 361 270 362 271 // Mobile tab click handlers 363 272 document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) { ··· 383 292 384 293 // Handle browser back/forward 385 294 window.addEventListener('hashchange', function() { 386 - var hash = window.location.hash.replace('#', '') || 'identity'; 295 + var hash = window.location.hash.replace('#', '') || 'storage'; 387 296 if (validTabs.indexOf(hash) !== -1) { 388 297 switchSettingsTab(hash); 389 298 } 390 299 }); 391 300 })(); 392 - 393 - // Hold Selection and Details Display 394 - document.addEventListener('DOMContentLoaded', function() { 395 - const holdSelect = document.getElementById('default-hold'); 396 - const holdDetails = document.getElementById('hold-details'); 397 - const holdForm = document.getElementById('hold-form'); 398 - 399 - if (holdSelect) { 400 - holdSelect.addEventListener('change', function() { 401 - const selectedDID = this.value; 402 - 403 - if (!selectedDID || !holdData[selectedDID]) { 404 - holdDetails.style.display = 'none'; 405 - return; 406 - } 407 - 408 - const hold = holdData[selectedDID]; 409 - 410 - document.getElementById('hold-did').textContent = hold.did; 411 - document.getElementById('hold-region').textContent = hold.region || 'Unknown'; 412 - 413 - // Set status badge 414 - const statusEl = document.getElementById('hold-status-badge'); 415 - if (hold.status === 'offline') { 416 - statusEl.innerHTML = '<span class="badge badge-sm badge-warning">Offline</span>'; 417 - } else if (hold.status === 'online') { 418 - statusEl.innerHTML = '<span class="badge badge-sm badge-success">Online</span>'; 419 - } else { 420 - statusEl.innerHTML = '<span class="text-base-content/60">Unknown</span>'; 421 - } 422 - 423 - // Set access level with badge 424 - const accessEl = document.getElementById('hold-access'); 425 - const accessLabel = { 426 - 'owner': 'Owner (Full Control)', 427 - 'crew': 'Crew Member', 428 - 'eligible': 'Open Registration', 429 - 'public': 'Public Access' 430 - }[hold.membership] || hold.membership; 431 - 432 - const badgeColor = { 433 - 'owner': 'badge-primary', 434 - 'crew': 'badge-secondary', 435 - 'eligible': 'badge-accent', 436 - 'public': 'badge-ghost' 437 - }[hold.membership] || ''; 438 - 439 - accessEl.innerHTML = '<span class="badge badge-sm ' + badgeColor + '">' + accessLabel + '</span>'; 440 - 441 - // Show permissions for crew members 442 - if (hold.membership === 'crew' && hold.permissions && hold.permissions.length > 0) { 443 - accessEl.innerHTML += '<br><span class="text-xs text-base-content/60">Permissions: ' + hold.permissions.join(', ') + '</span>'; 444 - } 445 - 446 - holdDetails.style.display = 'block'; 447 - }); 448 - 449 - // Re-fetch stowage and subscription when hold selection changes 450 - holdSelect.addEventListener('change', function() { 451 - var params = this.value ? '?hold_did=' + encodeURIComponent(this.value) : ''; 452 - htmx.ajax('GET', '/api/storage' + params, '#storage-stats'); 453 - htmx.ajax('GET', '/api/subscription' + params, {target: '#subscription-wrapper', swap: 'innerHTML'}); 454 - }); 455 - 456 - // Trigger on page load if a hold is already selected 457 - if (holdSelect.value) { 458 - holdSelect.dispatchEvent(new Event('change')); 459 - } 460 - } 461 - 462 - // HTMX success handler - no icon reinitialization needed with SVG sprites 463 - if (holdForm) { 464 - holdForm.addEventListener('htmx:afterSwap', function(event) { 465 - // SVG sprites don't need reinitialization 466 - }); 467 - } 468 - }); 469 301 470 302 // Account Deletion JavaScript 471 303 (function() {
+32
pkg/appview/templates/partials/hold_card.html
··· 1 + {{ define "hold_card" }} 2 + <div class="card bg-base-100 shadow-sm"> 3 + <!-- Header --> 4 + <div class="p-4 flex flex-wrap items-center gap-2"> 5 + <div class="flex-1 min-w-0"> 6 + <div class="flex items-center gap-2 flex-wrap"> 7 + <span class="text-warning" title="Active hold">&#9733;</span> 8 + <h3 class="font-semibold text-lg truncate">{{ .DisplayName }}</h3> 9 + <span class="badge badge-sm badge-warning">Active</span> 10 + {{ if eq .Membership "owner" }}<span class="badge badge-sm badge-primary">Owner</span> 11 + {{ else }}<span class="badge badge-sm badge-secondary">Crew</span>{{ end }} 12 + {{ if eq .Status "online" }}<span class="badge badge-sm badge-success gap-1">&#9679; Online</span> 13 + {{ else if eq .Status "offline" }}<span class="badge badge-sm badge-error gap-1">&#9679; Offline</span> 14 + {{ end }} 15 + </div> 16 + <code class="text-xs text-base-content/50 break-all">{{ .DID }}</code> 17 + </div> 18 + <div class="shrink-0"> 19 + </div> 20 + </div> 21 + 22 + <!-- Storage Stats (always visible, lazy-loaded) --> 23 + <div class="px-4 pb-4"> 24 + <div id="storage-stats-active" 25 + hx-get="/api/storage?hold_did={{ .DID | urlquery }}" 26 + hx-trigger="tab:storage from:body once" 27 + hx-swap="innerHTML"> 28 + <p class="flex items-center gap-2 text-sm text-base-content/50">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p> 29 + </div> 30 + </div> 31 + </div> 32 + {{ end }}
+36
pkg/appview/templates/partials/hold_selector.html
··· 1 + {{ define "hold_selector" }} 2 + <div class="card bg-base-100 shadow-sm p-4"> 3 + <form hx-post="/api/profile/default-hold" hx-swap="none" class="flex items-center gap-3 flex-wrap"> 4 + <label class="text-sm font-medium whitespace-nowrap" for="hold-select">Active Hold:</label> 5 + <select id="hold-select" name="hold_did" class="select select-bordered select-sm flex-1 min-w-0" 6 + onchange="this.form.requestSubmit()"> 7 + {{ if not .ActiveHold }} 8 + <option value="" selected>-- Select a hold --</option> 9 + {{ end }} 10 + 11 + <optgroup label="Your Holds"> 12 + {{ range .AllHolds }} 13 + {{ if ne .Membership "eligible" }} 14 + <option value="{{ .DID }}" {{ if .IsActive }}selected{{ end }}> 15 + {{ .DisplayName }}{{ if eq .Membership "owner" }} (Owner){{ else }} (Crew){{ end }}{{ if .Region }} &middot; {{ .Region }}{{ end }} 16 + </option> 17 + {{ end }} 18 + {{ end }} 19 + </optgroup> 20 + 21 + {{ range .AllHolds }}{{ if eq .Membership "eligible" }} 22 + <optgroup label="Available Holds"> 23 + {{ range $.AllHolds }} 24 + {{ if eq .Membership "eligible" }} 25 + <option value="{{ .DID }}"> 26 + {{ .DisplayName }}{{ if .Region }} &middot; {{ .Region }}{{ end }} (Join) 27 + </option> 28 + {{ end }} 29 + {{ end }} 30 + </optgroup> 31 + {{ break }}{{ end }}{{ end }} 32 + </select> 33 + <noscript><button type="submit" class="btn btn-sm btn-primary">Switch</button></noscript> 34 + </form> 35 + </div> 36 + {{ end }}
+47
pkg/appview/templates/partials/other_holds_table.html
··· 1 + {{ define "other_holds_table" }} 2 + <div class="card bg-base-100 shadow-sm"> 3 + <div class="p-4 pb-2"> 4 + <h3 class="text-sm font-semibold text-base-content/70">Other Holds</h3> 5 + </div> 6 + <div class="overflow-x-auto"> 7 + <table class="table table-sm"> 8 + <thead> 9 + <tr> 10 + <th>Hold</th> 11 + <th>Role</th> 12 + <th class="text-center">Status</th> 13 + <th class="text-right">Storage</th> 14 + </tr> 15 + </thead> 16 + <tbody> 17 + {{ range . }} 18 + <tr> 19 + <td> 20 + <span class="font-medium">{{ .DisplayName }}</span> 21 + </td> 22 + <td> 23 + {{ if eq .Membership "owner" }}<span class="badge badge-xs badge-primary">Owner</span> 24 + {{ else }}<span class="badge badge-xs badge-secondary">Crew</span>{{ end }} 25 + </td> 26 + <td class="text-center"> 27 + {{ if eq .Status "online" }}<span class="text-success" title="Online">&#9679;</span> 28 + {{ else if eq .Status "offline" }}<span class="text-error" title="Offline">&#9679;</span> 29 + {{ else }}<span class="text-base-content/30" title="Unknown">&#9679;</span> 30 + {{ end }} 31 + </td> 32 + <td class="text-right"> 33 + <span id="storage-compact-{{ sanitizeID .DID }}" 34 + hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true" 35 + hx-trigger="tab:storage from:body once" 36 + hx-swap="innerHTML" 37 + class="text-sm font-mono"> 38 + ... 39 + </span> 40 + </td> 41 + </tr> 42 + {{ end }} 43 + </tbody> 44 + </table> 45 + </div> 46 + </div> 47 + {{ end }}
-6
pkg/appview/templates/partials/storage_stats.html
··· 5 5 <span class="text-base-content/60">Tier:</span> 6 6 <span class="badge badge-xs badge-{{ .Tier }} font-semibold">{{ .Tier }}</span> 7 7 </div> 8 - {{ if .HasSupporterBadge }} 9 - <div class="flex justify-between items-center"> 10 - <span class="text-base-content/60">Profile Badge:</span> 11 - <span class="text-sm text-success flex items-center gap-1">{{ icon "badge-check" "size-4" }} Visible on your profile</span> 12 - </div> 13 - {{ end }} 14 8 {{ end }} 15 9 <div class="flex justify-between items-center"> 16 10 <span class="text-base-content/60">Storage:</span>
+36 -46
pkg/appview/templates/partials/subscription_info.html
··· 1 - {{ define "subscription_info" }} 2 - {{ if .HideBilling }} 3 - <!-- subscription: billing not available for this hold --> 4 - {{ else }} 5 - <section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4"> 6 - <h2 class="text-xl font-semibold">Subscription</h2> 7 - <p class="text-base-content/70">Manage your storage tier and billing.{{ if .HoldDisplayName }} Storage provided by <strong>{{ .HoldDisplayName }}</strong>.{{ end }}</p> 8 - 9 - {{ if .Error }} 10 - <div class="alert alert-error"> 11 - {{ icon "alert-circle" "size-5" }} {{ .Error }} 12 - </div> 13 - {{ else }} 14 - <!-- Current Plan --> 15 - <div class="bg-base-200 p-4 rounded-lg mb-4"> 16 - <div class="flex justify-between py-2 border-b border-base-300"> 17 - <span class="text-base-content/70">Current Tier:</span> 18 - <span class="font-bold capitalize">{{ .CurrentTier }}</span> 19 - </div> 20 - {{ if and .CrewTier (ne .CrewTier .CurrentTier) }} 21 - <div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1"> 22 - <span class="text-base-content/70">Crew Record Tier:</span> 23 - <span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span> 24 - </div> 25 - {{ end }} 26 - {{ if .SubscriptionID }} 27 - <div class="flex justify-between py-2"> 28 - <span class="text-base-content/70">Billing:</span> 29 - <span class="font-bold">{{ .BillingInterval }}</span> 30 - </div> 31 - <a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a> 32 - {{ end }} 33 - </div> 34 - 35 - <!-- Available Tiers --> 36 - {{ if .Tiers }} 37 - <h3 class="font-semibold">Available Plans</h3> 38 - <div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4"> 1 + {{ define "subscription_plans" }} 2 + {{ if not .HideBilling }} 3 + {{ if .Tiers }} 4 + <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 5 + <h3 class="text-xl font-semibold">Available Plans</h3> 6 + <div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4"> 39 7 {{ range .Tiers }} 40 8 <div class="border rounded-lg p-5 bg-base-200 relative flex flex-col{{ if .IsCurrent }} border-primary border-2{{ else }} border-base-300{{ end }}"> 41 9 {{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }} 42 - <div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div> 43 - <div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div> 44 - {{ if .Description }} 45 - <div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div> 10 + <div class="text-lg font-bold capitalize">{{ .Name }}</div> 11 + {{ if .Description }}<p class="text-sm text-base-content/60 mt-1">{{ .Description }}</p>{{ end }} 12 + {{ if .Features }} 13 + <ul class="text-sm text-base-content/60 mt-2 list-disc list-inside space-y-0.5"> 14 + {{ range .Features }}<li>{{ . }}</li>{{ end }} 15 + </ul> 46 16 {{ end }} 47 17 <div class="flex-1"></div> 48 - <div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div> 18 + <div class="mt-4"> 19 + {{ if and .IsCurrent (not $.SubscriptionID) (or .PriceCentsMonthly .PriceCentsYearly) }} 20 + {{/* Current tier with optional support pricing */}} 21 + <div class="text-2xl font-bold text-success">Free</div> 22 + {{ if .PriceMonthly }}<div class="text-sm text-base-content/60">{{ .PriceMonthly }} to support</div> 23 + {{ else if .PriceYearly }}<div class="text-sm text-base-content/60">{{ .PriceYearly }} to support</div>{{ end }} 24 + {{ else if .PriceMonthly }} 25 + <div class="text-2xl font-bold">{{ .PriceMonthly }}</div> 26 + {{ if .PriceYearly }}<div class="text-sm text-base-content/60">or {{ .PriceYearly }}</div>{{ end }} 27 + {{ else if .PriceYearly }} 28 + <div class="text-2xl font-bold">{{ .PriceYearly }}</div> 29 + {{ else }} 30 + <div class="text-2xl font-bold text-success">Free</div> 31 + {{ end }} 32 + </div> 49 33 {{ if not .IsCurrent }} 50 34 {{ if or .PriceCentsMonthly .PriceCentsYearly }} 51 35 {{ if $.SubscriptionID }} 52 - <a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a> 36 + <a href="/settings/subscription/portal" class="btn btn-primary w-full mt-3">Change Plan</a> 53 37 {{ else }} 54 - <a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a> 38 + <a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full mt-3">Upgrade</a> 55 39 {{ end }} 56 40 {{ end }} 41 + {{ else if and (not $.SubscriptionID) (or .PriceCentsMonthly .PriceCentsYearly) }} 42 + <a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-outline btn-primary w-full mt-3">Become a Supporter</a> 57 43 {{ end }} 58 44 </div> 59 45 {{ end }} 60 46 </div> 61 - {{ end }} 47 + {{ if .SubscriptionID }} 48 + <div class="flex justify-end"> 49 + <a href="/settings/subscription/portal" class="btn btn-outline btn-primary btn-sm">Manage Billing</a> 50 + </div> 62 51 {{ end }} 63 52 </section> 53 + {{ end }} 64 54 {{ end }} 65 55 {{ end }}
+4 -4
pkg/appview/templates/partials/webhooks_list.html
··· 2 2 <div class="space-y-6"> 3 3 <!-- Add Webhook Form --> 4 4 <form hx-post="/api/webhooks" 5 - hx-target="#webhooks-content" 5 + hx-target="#{{ .ContainerID }}" 6 6 hx-swap="innerHTML" 7 7 class="space-y-4 bg-base-200 rounded-lg p-4"> 8 8 <h3 class="font-semibold">Add Webhook</h3> ··· 79 79 </div> 80 80 <div class="flex gap-2 shrink-0"> 81 81 <button class="btn btn-xs btn-ghost" 82 - onclick="testWebhook('{{ .Rkey }}')" 82 + onclick="testWebhook('{{ .ID }}')" 83 83 title="Send test payload"> 84 84 Test 85 85 </button> 86 86 <button class="btn btn-xs btn-error btn-ghost" 87 - hx-delete="/api/webhooks/{{ .Rkey }}" 88 - hx-target="#webhooks-content" 87 + hx-delete="/api/webhooks/{{ .ID }}" 88 + hx-target="#{{ $.ContainerID }}" 89 89 hx-swap="innerHTML" 90 90 hx-confirm="Delete this webhook?" 91 91 title="Delete webhook">
+2
pkg/appview/ui.go
··· 195 195 // Replace special CSS selector characters with dashes 196 196 // e.g., "sha256:abc123" becomes "sha256-abc123" 197 197 // e.g., "v0.0.2" becomes "v0-0-2" 198 + // e.g., "did:web:172.28.0.3%3A8080" becomes "did-web-172-28-0-3-3A8080" 198 199 s = strings.ReplaceAll(s, ":", "-") 199 200 s = strings.ReplaceAll(s, ".", "-") 201 + s = strings.ReplaceAll(s, "%", "-") 200 202 return s 201 203 }, 202 204
+234
pkg/appview/webhooks/dispatch.go
··· 1 + package webhooks 2 + 3 + import ( 4 + "context" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "log/slog" 12 + "math/rand/v2" 13 + "net/http" 14 + "strings" 15 + "time" 16 + 17 + "atcr.io/pkg/appview/db" 18 + "atcr.io/pkg/atproto" 19 + ) 20 + 21 + // Dispatcher handles webhook delivery for scan notifications. 22 + // It reads webhooks from the appview DB and delivers payloads 23 + // with Discord/Slack formatting and HMAC signing. 24 + type Dispatcher struct { 25 + db db.DBTX 26 + meta atproto.AppviewMetadata 27 + } 28 + 29 + // NewDispatcher creates a new webhook dispatcher 30 + func NewDispatcher(database db.DBTX, meta atproto.AppviewMetadata) *Dispatcher { 31 + return &Dispatcher{ 32 + db: database, 33 + meta: meta, 34 + } 35 + } 36 + 37 + // DispatchForScan fires matching webhooks after a scan record arrives via Jetstream. 38 + // previousScan is nil for first-time scans. userHandle is used for payload enrichment. 39 + func (d *Dispatcher) DispatchForScan(ctx context.Context, scan, previousScan *db.Scan, userHandle, tag, holdEndpoint string) { 40 + webhooks, err := db.GetWebhooksForUser(d.db, scan.UserDID) 41 + if err != nil || len(webhooks) == 0 { 42 + return 43 + } 44 + 45 + isFirst := previousScan == nil 46 + isChanged := previousScan != nil && vulnCountsChanged(scan, previousScan) 47 + 48 + scanInfo := WebhookScanInfo{ 49 + ScannedAt: scan.ScannedAt.Format(time.RFC3339), 50 + ScannerVersion: scan.ScannerVersion, 51 + Vulnerabilities: WebhookVulnCounts{ 52 + Critical: scan.Critical, 53 + High: scan.High, 54 + Medium: scan.Medium, 55 + Low: scan.Low, 56 + Total: scan.Total, 57 + }, 58 + } 59 + 60 + manifestInfo := WebhookManifestInfo{ 61 + Digest: scan.ManifestDigest, 62 + Repository: scan.Repository, 63 + Tag: tag, 64 + UserDID: scan.UserDID, 65 + UserHandle: userHandle, 66 + } 67 + 68 + for _, wh := range webhooks { 69 + // Check each trigger condition against bitmask 70 + var triggers []string 71 + if wh.Triggers&TriggerFirst != 0 && isFirst { 72 + triggers = append(triggers, "scan:first") 73 + } 74 + if wh.Triggers&TriggerAll != 0 { 75 + triggers = append(triggers, "scan:all") 76 + } 77 + if wh.Triggers&TriggerChanged != 0 && isChanged { 78 + triggers = append(triggers, "scan:changed") 79 + } 80 + 81 + for _, trigger := range triggers { 82 + payload := WebhookPayload{ 83 + Trigger: trigger, 84 + HoldDID: scan.HoldDID, 85 + HoldEndpoint: holdEndpoint, 86 + Manifest: manifestInfo, 87 + Scan: scanInfo, 88 + } 89 + 90 + // Include previous counts for scan:changed 91 + if trigger == "scan:changed" && previousScan != nil { 92 + payload.Previous = &WebhookVulnCounts{ 93 + Critical: previousScan.Critical, 94 + High: previousScan.High, 95 + Medium: previousScan.Medium, 96 + Low: previousScan.Low, 97 + Total: previousScan.Total, 98 + } 99 + } 100 + 101 + payloadBytes, err := json.Marshal(payload) 102 + if err != nil { 103 + slog.Error("Failed to marshal webhook payload", "error", err) 104 + continue 105 + } 106 + 107 + go d.deliverWithRetry(wh.URL, wh.Secret, payloadBytes) 108 + } 109 + } 110 + } 111 + 112 + // DeliverTest sends a test payload to a specific webhook (synchronous, single attempt) 113 + func (d *Dispatcher) DeliverTest(ctx context.Context, webhookID, userDID, userHandle string) (bool, error) { 114 + wh, err := db.GetWebhookByID(d.db, webhookID) 115 + if err != nil { 116 + return false, err 117 + } 118 + if wh.UserDID != userDID { 119 + return false, fmt.Errorf("unauthorized") 120 + } 121 + 122 + // Randomize vulnerability counts so each test shows a different severity color 123 + critical := rand.IntN(3) 124 + high := rand.IntN(5) 125 + medium := rand.IntN(8) 126 + low := rand.IntN(10) 127 + total := critical + high + medium + low 128 + 129 + payload := WebhookPayload{ 130 + Trigger: "test", 131 + Manifest: WebhookManifestInfo{ 132 + Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", 133 + Repository: "test-repo", 134 + Tag: "latest", 135 + UserDID: userDID, 136 + UserHandle: userHandle, 137 + }, 138 + Scan: WebhookScanInfo{ 139 + ScannedAt: time.Now().Format(time.RFC3339), 140 + ScannerVersion: "atcr-scanner-v1.0.0", 141 + Vulnerabilities: WebhookVulnCounts{ 142 + Critical: critical, High: high, Medium: medium, Low: low, Total: total, 143 + }, 144 + }, 145 + } 146 + 147 + payloadBytes, _ := json.Marshal(payload) 148 + success := d.attemptDelivery(wh.URL, wh.Secret, payloadBytes) 149 + return success, nil 150 + } 151 + 152 + // deliverWithRetry attempts to deliver a webhook with exponential backoff 153 + func (d *Dispatcher) deliverWithRetry(webhookURL, secret string, payload []byte) { 154 + delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute} 155 + for attempt, delay := range delays { 156 + if attempt > 0 { 157 + time.Sleep(delay) 158 + } 159 + if d.attemptDelivery(webhookURL, secret, payload) { 160 + return 161 + } 162 + } 163 + slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL)) 164 + } 165 + 166 + // attemptDelivery sends a single webhook HTTP POST 167 + func (d *Dispatcher) attemptDelivery(webhookURL, secret string, payload []byte) bool { 168 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 169 + defer cancel() 170 + 171 + // Reformat payload for platform-specific webhook APIs 172 + sendPayload := payload 173 + if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) { 174 + var p WebhookPayload 175 + if err := json.Unmarshal(payload, &p); err == nil { 176 + var formatted []byte 177 + var fmtErr error 178 + if isDiscordWebhook(webhookURL) { 179 + formatted, fmtErr = formatDiscordPayload(p, d.meta) 180 + } else { 181 + formatted, fmtErr = formatSlackPayload(p, d.meta) 182 + } 183 + if fmtErr == nil { 184 + sendPayload = formatted 185 + } 186 + } 187 + } 188 + 189 + req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(sendPayload))) 190 + if err != nil { 191 + slog.Warn("Failed to create webhook request", "error", err) 192 + return false 193 + } 194 + 195 + req.Header.Set("Content-Type", "application/json") 196 + req.Header.Set("User-Agent", d.meta.ClientShortName+"-Webhook/1.0") 197 + 198 + // HMAC signing if secret is set (signs the actual payload sent) 199 + if secret != "" { 200 + mac := hmac.New(sha256.New, []byte(secret)) 201 + mac.Write(sendPayload) 202 + sig := hex.EncodeToString(mac.Sum(nil)) 203 + req.Header.Set("X-Webhook-Signature-256", "sha256="+sig) 204 + } 205 + 206 + client := &http.Client{Timeout: 10 * time.Second} 207 + resp, err := client.Do(req) 208 + if err != nil { 209 + slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err) 210 + return false 211 + } 212 + defer resp.Body.Close() 213 + 214 + if resp.StatusCode >= 200 && resp.StatusCode < 300 { 215 + slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode) 216 + return true 217 + } 218 + 219 + // Read response body for debugging 220 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) 221 + slog.Warn("Webhook delivery got non-2xx response", 222 + "url", maskURL(webhookURL), 223 + "status", resp.StatusCode, 224 + "body", string(body)) 225 + return false 226 + } 227 + 228 + // vulnCountsChanged checks if vulnerability counts differ between scans 229 + func vulnCountsChanged(current, previous *db.Scan) bool { 230 + return current.Critical != previous.Critical || 231 + current.High != previous.High || 232 + current.Medium != previous.Medium || 233 + current.Low != previous.Low 234 + }
+184
pkg/appview/webhooks/format.go
··· 1 + package webhooks 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/url" 7 + "strings" 8 + 9 + "atcr.io/pkg/atproto" 10 + ) 11 + 12 + // maskURL masks a URL for display (shows scheme + host, hides path/query) 13 + func maskURL(rawURL string) string { 14 + u, err := url.Parse(rawURL) 15 + if err != nil { 16 + if len(rawURL) > 30 { 17 + return rawURL[:30] + "***" 18 + } 19 + return rawURL 20 + } 21 + masked := u.Scheme + "://" + u.Host 22 + if u.Path != "" && u.Path != "/" { 23 + masked += "/***" 24 + } 25 + return masked 26 + } 27 + 28 + // isDiscordWebhook checks if the URL points to a Discord webhook endpoint 29 + func isDiscordWebhook(rawURL string) bool { 30 + u, err := url.Parse(rawURL) 31 + if err != nil { 32 + return false 33 + } 34 + return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com") 35 + } 36 + 37 + // isSlackWebhook checks if the URL points to a Slack webhook endpoint 38 + func isSlackWebhook(rawURL string) bool { 39 + u, err := url.Parse(rawURL) 40 + if err != nil { 41 + return false 42 + } 43 + return u.Host == "hooks.slack.com" 44 + } 45 + 46 + // webhookSeverityColor returns a color int based on the highest severity present 47 + func webhookSeverityColor(vulns WebhookVulnCounts) int { 48 + switch { 49 + case vulns.Critical > 0: 50 + return 0xED4245 // red 51 + case vulns.High > 0: 52 + return 0xFFA500 // orange 53 + case vulns.Medium > 0: 54 + return 0xFEE75C // yellow 55 + case vulns.Low > 0: 56 + return 0x57F287 // green 57 + default: 58 + return 0x95A5A6 // grey 59 + } 60 + } 61 + 62 + // webhookSeverityHex returns a hex color string (e.g., "#ED4245") 63 + func webhookSeverityHex(vulns WebhookVulnCounts) string { 64 + return fmt.Sprintf("#%06X", webhookSeverityColor(vulns)) 65 + } 66 + 67 + // formatVulnDescription builds a vulnerability summary with colored square emojis 68 + func formatVulnDescription(v WebhookVulnCounts, digest string) string { 69 + var lines []string 70 + 71 + if len(digest) > 19 { 72 + lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"...")) 73 + } 74 + 75 + if v.Total == 0 { 76 + lines = append(lines, "🟩 No vulnerabilities found") 77 + } else { 78 + if v.Critical > 0 { 79 + lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical)) 80 + } 81 + if v.High > 0 { 82 + lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High)) 83 + } 84 + if v.Medium > 0 { 85 + lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium)) 86 + } 87 + if v.Low > 0 { 88 + lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low)) 89 + } 90 + } 91 + 92 + return strings.Join(lines, "\n") 93 + } 94 + 95 + // formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format 96 + func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 97 + appviewURL := meta.BaseURL 98 + title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag) 99 + 100 + description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest) 101 + 102 + // Add previous counts for scan:changed 103 + if p.Trigger == "scan:changed" && p.Previous != nil { 104 + description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d", 105 + p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low) 106 + } 107 + 108 + embed := map[string]any{ 109 + "title": title, 110 + "url": appviewURL, 111 + "description": description, 112 + "color": webhookSeverityColor(p.Scan.Vulnerabilities), 113 + "footer": map[string]string{ 114 + "text": meta.ClientShortName, 115 + "icon_url": meta.FaviconURL, 116 + }, 117 + "timestamp": p.Scan.ScannedAt, 118 + } 119 + 120 + // Add author, repo link, and OG image when handle is available 121 + if p.Manifest.UserHandle != "" { 122 + embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 123 + embed["author"] = map[string]string{ 124 + "name": p.Manifest.UserHandle, 125 + "url": appviewURL + "/u/" + p.Manifest.UserHandle, 126 + } 127 + embed["image"] = map[string]string{ 128 + "url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository), 129 + } 130 + } else { 131 + embed["image"] = map[string]string{ 132 + "url": appviewURL + "/og/home", 133 + } 134 + } 135 + 136 + payload := map[string]any{ 137 + "username": meta.ClientShortName, 138 + "avatar_url": meta.FaviconURL, 139 + "embeds": []any{embed}, 140 + } 141 + return json.Marshal(payload) 142 + } 143 + 144 + // formatSlackPayload wraps an ATCR webhook payload in Slack's message format 145 + func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 146 + appviewURL := meta.BaseURL 147 + title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag) 148 + 149 + v := p.Scan.Vulnerabilities 150 + fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low", 151 + title, v.Critical, v.High, v.Medium, v.Low) 152 + 153 + description := formatVulnDescription(v, p.Manifest.Digest) 154 + 155 + // Add previous counts for scan:changed 156 + if p.Trigger == "scan:changed" && p.Previous != nil { 157 + description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d", 158 + p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low) 159 + } 160 + 161 + attachment := map[string]any{ 162 + "fallback": fallback, 163 + "color": webhookSeverityHex(v), 164 + "title": title, 165 + "text": description, 166 + "footer": meta.ClientShortName, 167 + "footer_icon": meta.FaviconURL, 168 + "ts": p.Scan.ScannedAt, 169 + } 170 + 171 + // Add repo link when handle is available 172 + if p.Manifest.UserHandle != "" { 173 + attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 174 + attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 175 + attachment["author_name"] = p.Manifest.UserHandle 176 + attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle 177 + } 178 + 179 + payload := map[string]any{ 180 + "text": fallback, 181 + "attachments": []any{attachment}, 182 + } 183 + return json.Marshal(payload) 184 + }
+44
pkg/appview/webhooks/types.go
··· 1 + // Package webhooks provides webhook dispatch and formatting for scan notifications. 2 + package webhooks 3 + 4 + // Webhook trigger bitmask constants 5 + const ( 6 + TriggerFirst = 0x01 // First-time scan (no previous scan record) 7 + TriggerAll = 0x02 // Every scan completion 8 + TriggerChanged = 0x04 // Vulnerability counts changed from previous 9 + ) 10 + 11 + // WebhookPayload is the JSON body sent to webhook URLs 12 + type WebhookPayload struct { 13 + Trigger string `json:"trigger"` 14 + HoldDID string `json:"holdDid"` 15 + HoldEndpoint string `json:"holdEndpoint"` 16 + Manifest WebhookManifestInfo `json:"manifest"` 17 + Scan WebhookScanInfo `json:"scan"` 18 + Previous *WebhookVulnCounts `json:"previous"` 19 + } 20 + 21 + // WebhookManifestInfo describes the scanned manifest 22 + type WebhookManifestInfo struct { 23 + Digest string `json:"digest"` 24 + Repository string `json:"repository"` 25 + Tag string `json:"tag"` 26 + UserDID string `json:"userDid"` 27 + UserHandle string `json:"userHandle,omitempty"` 28 + } 29 + 30 + // WebhookScanInfo describes the scan results 31 + type WebhookScanInfo struct { 32 + ScannedAt string `json:"scannedAt"` 33 + ScannerVersion string `json:"scannerVersion"` 34 + Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"` 35 + } 36 + 37 + // WebhookVulnCounts contains vulnerability counts by severity 38 + type WebhookVulnCounts struct { 39 + Critical int `json:"critical"` 40 + High int `json:"high"` 41 + Medium int `json:"medium"` 42 + Low int `json:"low"` 43 + Total int `json:"total"` 44 + }
+2 -298
pkg/atproto/cbor_gen.go
··· 377 377 } 378 378 379 379 cw := cbg.NewCborWriter(w) 380 - fieldCount := 9 380 + fieldCount := 8 381 381 382 382 if t.Region == "" { 383 383 fieldCount-- 384 384 } 385 385 386 386 if t.Successor == "" { 387 - fieldCount-- 388 - } 389 - 390 - if t.SupporterBadgeTiers == nil { 391 387 fieldCount-- 392 388 } 393 389 ··· 562 558 563 559 if err := cbg.WriteBool(w, t.EnableBlueskyPosts); err != nil { 564 560 return err 565 - } 566 - 567 - // t.SupporterBadgeTiers ([]string) (slice) 568 - if t.SupporterBadgeTiers != nil { 569 - 570 - if len("supporterBadgeTiers") > 8192 { 571 - return xerrors.Errorf("Value in field \"supporterBadgeTiers\" was too long") 572 - } 573 - 574 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("supporterBadgeTiers"))); err != nil { 575 - return err 576 - } 577 - if _, err := cw.WriteString(string("supporterBadgeTiers")); err != nil { 578 - return err 579 - } 580 - 581 - if len(t.SupporterBadgeTiers) > 8192 { 582 - return xerrors.Errorf("Slice value in field t.SupporterBadgeTiers was too long") 583 - } 584 - 585 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.SupporterBadgeTiers))); err != nil { 586 - return err 587 - } 588 - for _, v := range t.SupporterBadgeTiers { 589 - if len(v) > 8192 { 590 - return xerrors.Errorf("Value in field v was too long") 591 - } 592 - 593 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 594 - return err 595 - } 596 - if _, err := cw.WriteString(string(v)); err != nil { 597 - return err 598 - } 599 - 600 - } 601 561 } 602 562 return nil 603 563 } ··· 627 587 628 588 n := extra 629 589 630 - nameBuf := make([]byte, 19) 590 + nameBuf := make([]byte, 18) 631 591 for i := uint64(0); i < n; i++ { 632 592 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 633 593 if err != nil { ··· 751 711 t.EnableBlueskyPosts = true 752 712 default: 753 713 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 754 - } 755 - // t.SupporterBadgeTiers ([]string) (slice) 756 - case "supporterBadgeTiers": 757 - 758 - maj, extra, err = cr.ReadHeader() 759 - if err != nil { 760 - return err 761 - } 762 - 763 - if extra > 8192 { 764 - return fmt.Errorf("t.SupporterBadgeTiers: array too large (%d)", extra) 765 - } 766 - 767 - if maj != cbg.MajArray { 768 - return fmt.Errorf("expected cbor array") 769 - } 770 - 771 - if extra > 0 { 772 - t.SupporterBadgeTiers = make([]string, extra) 773 - } 774 - 775 - for i := 0; i < int(extra); i++ { 776 - { 777 - var maj byte 778 - var extra uint64 779 - var err error 780 - _ = maj 781 - _ = extra 782 - _ = err 783 - 784 - { 785 - sval, err := cbg.ReadStringWithMax(cr, 8192) 786 - if err != nil { 787 - return err 788 - } 789 - 790 - t.SupporterBadgeTiers[i] = string(sval) 791 - } 792 - 793 - } 794 714 } 795 715 796 716 default: ··· 2505 2425 2506 2426 return nil 2507 2427 } 2508 - func (t *HoldWebhookRecord) MarshalCBOR(w io.Writer) error { 2509 - if t == nil { 2510 - _, err := w.Write(cbg.CborNull) 2511 - return err 2512 - } 2513 - 2514 - cw := cbg.NewCborWriter(w) 2515 - 2516 - if _, err := cw.Write([]byte{164}); err != nil { 2517 - return err 2518 - } 2519 - 2520 - // t.Type (string) (string) 2521 - if len("$type") > 8192 { 2522 - return xerrors.Errorf("Value in field \"$type\" was too long") 2523 - } 2524 - 2525 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2526 - return err 2527 - } 2528 - if _, err := cw.WriteString(string("$type")); err != nil { 2529 - return err 2530 - } 2531 - 2532 - if len(t.Type) > 8192 { 2533 - return xerrors.Errorf("Value in field t.Type was too long") 2534 - } 2535 - 2536 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 2537 - return err 2538 - } 2539 - if _, err := cw.WriteString(string(t.Type)); err != nil { 2540 - return err 2541 - } 2542 - 2543 - // t.UserDID (string) (string) 2544 - if len("userDid") > 8192 { 2545 - return xerrors.Errorf("Value in field \"userDid\" was too long") 2546 - } 2547 - 2548 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("userDid"))); err != nil { 2549 - return err 2550 - } 2551 - if _, err := cw.WriteString(string("userDid")); err != nil { 2552 - return err 2553 - } 2554 - 2555 - if len(t.UserDID) > 8192 { 2556 - return xerrors.Errorf("Value in field t.UserDID was too long") 2557 - } 2558 - 2559 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil { 2560 - return err 2561 - } 2562 - if _, err := cw.WriteString(string(t.UserDID)); err != nil { 2563 - return err 2564 - } 2565 - 2566 - // t.Triggers (int64) (int64) 2567 - if len("triggers") > 8192 { 2568 - return xerrors.Errorf("Value in field \"triggers\" was too long") 2569 - } 2570 - 2571 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("triggers"))); err != nil { 2572 - return err 2573 - } 2574 - if _, err := cw.WriteString(string("triggers")); err != nil { 2575 - return err 2576 - } 2577 - 2578 - if t.Triggers >= 0 { 2579 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Triggers)); err != nil { 2580 - return err 2581 - } 2582 - } else { 2583 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Triggers-1)); err != nil { 2584 - return err 2585 - } 2586 - } 2587 - 2588 - // t.CreatedAt (string) (string) 2589 - if len("createdAt") > 8192 { 2590 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 2591 - } 2592 - 2593 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2594 - return err 2595 - } 2596 - if _, err := cw.WriteString(string("createdAt")); err != nil { 2597 - return err 2598 - } 2599 - 2600 - if len(t.CreatedAt) > 8192 { 2601 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 2602 - } 2603 - 2604 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2605 - return err 2606 - } 2607 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2608 - return err 2609 - } 2610 - return nil 2611 - } 2612 - 2613 - func (t *HoldWebhookRecord) UnmarshalCBOR(r io.Reader) (err error) { 2614 - *t = HoldWebhookRecord{} 2615 - 2616 - cr := cbg.NewCborReader(r) 2617 - 2618 - maj, extra, err := cr.ReadHeader() 2619 - if err != nil { 2620 - return err 2621 - } 2622 - defer func() { 2623 - if err == io.EOF { 2624 - err = io.ErrUnexpectedEOF 2625 - } 2626 - }() 2627 - 2628 - if maj != cbg.MajMap { 2629 - return fmt.Errorf("cbor input should be of type map") 2630 - } 2631 - 2632 - if extra > cbg.MaxLength { 2633 - return fmt.Errorf("HoldWebhookRecord: map struct too large (%d)", extra) 2634 - } 2635 - 2636 - n := extra 2637 - 2638 - nameBuf := make([]byte, 9) 2639 - for i := uint64(0); i < n; i++ { 2640 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2641 - if err != nil { 2642 - return err 2643 - } 2644 - 2645 - if !ok { 2646 - // Field doesn't exist on this type, so ignore it 2647 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2648 - return err 2649 - } 2650 - continue 2651 - } 2652 - 2653 - switch string(nameBuf[:nameLen]) { 2654 - // t.Type (string) (string) 2655 - case "$type": 2656 - 2657 - { 2658 - sval, err := cbg.ReadStringWithMax(cr, 8192) 2659 - if err != nil { 2660 - return err 2661 - } 2662 - 2663 - t.Type = string(sval) 2664 - } 2665 - // t.UserDID (string) (string) 2666 - case "userDid": 2667 - 2668 - { 2669 - sval, err := cbg.ReadStringWithMax(cr, 8192) 2670 - if err != nil { 2671 - return err 2672 - } 2673 - 2674 - t.UserDID = string(sval) 2675 - } 2676 - // t.Triggers (int64) (int64) 2677 - case "triggers": 2678 - { 2679 - maj, extra, err := cr.ReadHeader() 2680 - if err != nil { 2681 - return err 2682 - } 2683 - var extraI int64 2684 - switch maj { 2685 - case cbg.MajUnsignedInt: 2686 - extraI = int64(extra) 2687 - if extraI < 0 { 2688 - return fmt.Errorf("int64 positive overflow") 2689 - } 2690 - case cbg.MajNegativeInt: 2691 - extraI = int64(extra) 2692 - if extraI < 0 { 2693 - return fmt.Errorf("int64 negative overflow") 2694 - } 2695 - extraI = -1 - extraI 2696 - default: 2697 - return fmt.Errorf("wrong type for int64 field: %d", maj) 2698 - } 2699 - 2700 - t.Triggers = int64(extraI) 2701 - } 2702 - // t.CreatedAt (string) (string) 2703 - case "createdAt": 2704 - 2705 - { 2706 - sval, err := cbg.ReadStringWithMax(cr, 8192) 2707 - if err != nil { 2708 - return err 2709 - } 2710 - 2711 - t.CreatedAt = string(sval) 2712 - } 2713 - 2714 - default: 2715 - // Field doesn't exist on this type, so ignore it 2716 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2717 - return err 2718 - } 2719 - } 2720 - } 2721 - 2722 - return nil 2723 - }
+16 -26
pkg/atproto/endpoints.go
··· 85 85 // Auth: Shared secret (query param or header) 86 86 // Response: Stream of scan job events (JSON) 87 87 HoldSubscribeScanJobs = "/xrpc/io.atcr.hold.subscribeScanJobs" 88 - 89 - // HoldListWebhooks lists webhook configurations for a user. 90 - // Method: GET 91 - // Query: userDid={did} 92 - // Response: {webhooks: [...], limits: {max, allTriggers}} 93 - HoldListWebhooks = "/xrpc/io.atcr.hold.listWebhooks" 94 - 95 - // HoldAddWebhook creates a new webhook configuration. 96 - // Method: POST 97 - // Request: {userDid, url, secret, triggers} 98 - // Response: {rkey, cid} 99 - HoldAddWebhook = "/xrpc/io.atcr.hold.addWebhook" 100 - 101 - // HoldDeleteWebhook deletes a webhook configuration. 102 - // Method: POST 103 - // Request: {rkey} 104 - // Response: {success: true} 105 - HoldDeleteWebhook = "/xrpc/io.atcr.hold.deleteWebhook" 106 - 107 - // HoldTestWebhook sends a test payload to a webhook. 108 - // Method: POST 109 - // Request: {rkey} 110 - // Response: {statusCode, success} 111 - HoldTestWebhook = "/xrpc/io.atcr.hold.testWebhook" 112 - 113 - // Future: HoldDelegateAccess = "/xrpc/io.atcr.hold.delegateAccess" 114 88 ) 115 89 116 90 // ATProto sync endpoints (com.atproto.sync.*) ··· 267 241 // Query: handle={handle} 268 242 // Response: {"did": "did:plc:..."} 269 243 IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle" 244 + ) 245 + 246 + // Hold billing/tier endpoints (io.atcr.hold.*) 247 + // 248 + // These endpoints manage billing tiers on hold services. 249 + const ( 250 + // HoldUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview. 251 + // Method: POST 252 + // Request: {"userDid": "did:...", "tierRank": 0} 253 + // Response: {"tierName": "deckhand"} 254 + HoldUpdateCrewTier = "/xrpc/io.atcr.hold.updateCrewTier" 255 + 256 + // HoldListTiers lists the hold's available tiers with storage quotas and features. 257 + // Method: GET 258 + // Response: {"tiers": [{"name": "deckhand", "quotaBytes": 5368709120, "quotaFormatted": "5.0 GB", "scanOnPush": false}]} 259 + HoldListTiers = "/xrpc/io.atcr.hold.listTiers" 270 260 ) 271 261 272 262 // Appview metadata endpoint (io.atcr.*)
-1
pkg/atproto/generate.go
··· 33 33 atproto.TangledProfileRecord{}, 34 34 atproto.StatsRecord{}, 35 35 atproto.ScanRecord{}, 36 - atproto.HoldWebhookRecord{}, 37 36 ); err != nil { 38 37 fmt.Printf("Failed to generate CBOR encoders: %v\n", err) 39 38 os.Exit(1)
+8 -65
pkg/atproto/lexicon.go
··· 64 64 // RepoPageCollection is the collection name for repository page metadata 65 65 // Stored in user's PDS with rkey = repository name 66 66 RepoPageCollection = "io.atcr.repo.page" 67 - 68 - // SailorWebhookCollection is the collection name for webhook configs in user's PDS 69 - SailorWebhookCollection = "io.atcr.sailor.webhook" 70 - 71 - // WebhookCollection is the collection name for webhook records in hold's embedded PDS 72 - WebhookCollection = "io.atcr.hold.webhook" 73 67 ) 74 68 75 69 // ManifestRecord represents a container image manifest stored in ATProto ··· 667 661 // Stored in the hold's embedded PDS to identify the hold owner and settings 668 662 // Uses CBOR encoding for efficient storage in hold's carstore 669 663 type CaptainRecord struct { 670 - Type string `json:"$type" cborgen:"$type"` 671 - Owner string `json:"owner" cborgen:"owner"` // DID of hold owner 672 - Public bool `json:"public" cborgen:"public"` // Public read access 673 - AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 674 - EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 675 - DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 676 - Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional) 677 - Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect) 678 - SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles 664 + Type string `json:"$type" cborgen:"$type"` 665 + Owner string `json:"owner" cborgen:"owner"` // DID of hold owner 666 + Public bool `json:"public" cborgen:"public"` // Public read access 667 + AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 668 + EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 669 + DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 670 + Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional) 671 + Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect) 679 672 } 680 673 681 674 // CrewRecord represents a crew member in the hold ··· 820 813 func ScanRecordKey(manifestDigest string) string { 821 814 // Remove the "sha256:" prefix - the hex digest is already a valid rkey 822 815 return strings.TrimPrefix(manifestDigest, "sha256:") 823 - } 824 - 825 - // Webhook trigger bitmask constants 826 - const ( 827 - TriggerFirst = 0x01 // First-time scan (no previous scan record) 828 - TriggerAll = 0x02 // Every scan completion 829 - TriggerChanged = 0x04 // Vulnerability counts changed from previous 830 - ) 831 - 832 - // SailorWebhookRecord represents a webhook config in the user's PDS 833 - // Links to a private HoldWebhookRecord via privateCid 834 - type SailorWebhookRecord struct { 835 - Type string `json:"$type"` 836 - HoldDID string `json:"holdDid"` 837 - Triggers int `json:"triggers"` 838 - PrivateCID string `json:"privateCid"` 839 - CreatedAt string `json:"createdAt"` 840 - UpdatedAt string `json:"updatedAt"` 841 - } 842 - 843 - // NewSailorWebhookRecord creates a new sailor webhook record 844 - func NewSailorWebhookRecord(holdDID string, triggers int, privateCID string) *SailorWebhookRecord { 845 - now := time.Now().Format(time.RFC3339) 846 - return &SailorWebhookRecord{ 847 - Type: SailorWebhookCollection, 848 - HoldDID: holdDID, 849 - Triggers: triggers, 850 - PrivateCID: privateCID, 851 - CreatedAt: now, 852 - UpdatedAt: now, 853 - } 854 - } 855 - 856 - // HoldWebhookRecord represents a webhook record in the hold's embedded PDS 857 - // The actual URL and secret are stored in SQLite (never in ATProto records) 858 - type HoldWebhookRecord struct { 859 - Type string `json:"$type" cborgen:"$type"` 860 - UserDID string `json:"userDid" cborgen:"userDid"` 861 - Triggers int64 `json:"triggers" cborgen:"triggers"` 862 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 863 - } 864 - 865 - // NewHoldWebhookRecord creates a new hold webhook record 866 - func NewHoldWebhookRecord(userDID string, triggers int) *HoldWebhookRecord { 867 - return &HoldWebhookRecord{ 868 - Type: WebhookCollection, 869 - UserDID: userDID, 870 - Triggers: int64(triggers), 871 - CreatedAt: time.Now().Format(time.RFC3339), 872 - } 873 816 } 874 817 875 818 // TangledProfileRecord represents a Tangled profile for the hold
+77
pkg/auth/appview_token.go
··· 1 + package auth 2 + 3 + import ( 4 + "crypto/ecdh" 5 + "crypto/ecdsa" 6 + "crypto/x509" 7 + "fmt" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 + "github.com/golang-jwt/jwt/v5" 12 + ) 13 + 14 + // CreateAppviewServiceToken creates a short-lived ES256 JWT for appview→hold communication. 15 + // The token authenticates the appview when calling hold XRPC endpoints like updateCrewTier. 16 + // 17 + // Claims: 18 + // - iss: appview DID (e.g. did:web:atcr.io) 19 + // - aud: hold DID (e.g. did:web:hold01.atcr.io) 20 + // - sub: user DID being acted upon 21 + // - exp: now + 60s 22 + // - iat: now 23 + func CreateAppviewServiceToken(privateKey *atcrypto.PrivateKeyP256, appviewDID, holdDID, userDID string) (string, error) { 24 + now := time.Now() 25 + 26 + claims := jwt.RegisteredClaims{ 27 + Issuer: appviewDID, 28 + Audience: jwt.ClaimStrings{holdDID}, 29 + Subject: userDID, 30 + ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)), 31 + IssuedAt: jwt.NewNumericDate(now), 32 + } 33 + 34 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 35 + 36 + ecKey, err := P256ToECDSA(privateKey) 37 + if err != nil { 38 + return "", fmt.Errorf("failed to extract ECDSA key: %w", err) 39 + } 40 + 41 + signed, err := token.SignedString(ecKey) 42 + if err != nil { 43 + return "", fmt.Errorf("failed to sign token: %w", err) 44 + } 45 + 46 + return signed, nil 47 + } 48 + 49 + // P256ToECDSA converts an atcrypto P-256 private key to a stdlib *ecdsa.PrivateKey. 50 + // This is needed because golang-jwt requires stdlib crypto types, while atcrypto 51 + // wraps them in its own types. We re-parse via PKCS8 encoding round-trip. 52 + func P256ToECDSA(key *atcrypto.PrivateKeyP256) (*ecdsa.PrivateKey, error) { 53 + rawBytes := key.Bytes() // 32-byte raw scalar 54 + 55 + // Parse raw bytes as ecdh key, then convert via PKCS8 round-trip (same as atcrypto does) 56 + ecdhKey, err := ecdh.P256().NewPrivateKey(rawBytes) 57 + if err != nil { 58 + return nil, fmt.Errorf("failed to parse P-256 raw bytes: %w", err) 59 + } 60 + 61 + pkcs8, err := x509.MarshalPKCS8PrivateKey(ecdhKey) 62 + if err != nil { 63 + return nil, fmt.Errorf("failed to marshal PKCS8: %w", err) 64 + } 65 + 66 + parsed, err := x509.ParsePKCS8PrivateKey(pkcs8) 67 + if err != nil { 68 + return nil, fmt.Errorf("failed to parse PKCS8: %w", err) 69 + } 70 + 71 + ecdsaKey, ok := parsed.(*ecdsa.PrivateKey) 72 + if !ok { 73 + return nil, fmt.Errorf("parsed key is not ECDSA") 74 + } 75 + 76 + return ecdsaKey, nil 77 + }
+730
pkg/billing/billing.go
··· 1 + //go:build billing 2 + 3 + package billing 4 + 5 + import ( 6 + "context" 7 + "encoding/json" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "net/http" 12 + "os" 13 + "strings" 14 + "sync" 15 + "time" 16 + 17 + "atcr.io/pkg/appview/holdclient" 18 + "github.com/bluesky-social/indigo/atproto/atcrypto" 19 + 20 + "github.com/stripe/stripe-go/v84" 21 + portalsession "github.com/stripe/stripe-go/v84/billingportal/session" 22 + "github.com/stripe/stripe-go/v84/checkout/session" 23 + "github.com/stripe/stripe-go/v84/customer" 24 + "github.com/stripe/stripe-go/v84/price" 25 + "github.com/stripe/stripe-go/v84/subscription" 26 + "github.com/stripe/stripe-go/v84/webhook" 27 + ) 28 + 29 + // Manager handles Stripe billing and pushes tier updates to managed holds. 30 + type Manager struct { 31 + cfg *Config 32 + privateKey *atcrypto.PrivateKeyP256 33 + appviewDID string 34 + managedHolds []string 35 + baseURL string 36 + stripeKey string 37 + webhookSecret string 38 + 39 + // Captain checker: bypasses billing for hold owners 40 + captainChecker CaptainChecker 41 + 42 + // Customer cache: DID → Stripe customer 43 + customerCache map[string]*cachedCustomer 44 + customerCacheMu sync.RWMutex 45 + 46 + // Price cache: Stripe price ID → unit amount in cents 47 + priceCache map[string]*cachedPrice 48 + priceCacheMu sync.RWMutex 49 + 50 + // Hold tier cache: holdDID → tier list 51 + holdTierCache map[string]*cachedHoldTiers 52 + holdTierCacheMu sync.RWMutex 53 + } 54 + 55 + type cachedHoldTiers struct { 56 + tiers []holdclient.HoldTierInfo 57 + expiresAt time.Time 58 + } 59 + 60 + type cachedCustomer struct { 61 + customer *stripe.Customer 62 + expiresAt time.Time 63 + } 64 + 65 + type cachedPrice struct { 66 + unitAmount int64 67 + expiresAt time.Time 68 + } 69 + 70 + const customerCacheTTL = 10 * time.Minute 71 + const priceCacheTTL = 1 * time.Hour 72 + 73 + // New creates a new billing manager with Stripe integration. 74 + // Env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take precedence over config values. 75 + func New(cfg *Config, privateKey *atcrypto.PrivateKeyP256, appviewDID string, managedHolds []string, baseURL string) *Manager { 76 + stripeKey := os.Getenv("STRIPE_SECRET_KEY") 77 + if stripeKey == "" { 78 + stripeKey = cfg.StripeSecretKey 79 + } 80 + if stripeKey != "" { 81 + stripe.Key = stripeKey 82 + } 83 + 84 + webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") 85 + if webhookSecret == "" { 86 + webhookSecret = cfg.WebhookSecret 87 + } 88 + 89 + return &Manager{ 90 + cfg: cfg, 91 + privateKey: privateKey, 92 + appviewDID: appviewDID, 93 + managedHolds: managedHolds, 94 + baseURL: baseURL, 95 + stripeKey: stripeKey, 96 + webhookSecret: webhookSecret, 97 + customerCache: make(map[string]*cachedCustomer), 98 + priceCache: make(map[string]*cachedPrice), 99 + holdTierCache: make(map[string]*cachedHoldTiers), 100 + } 101 + } 102 + 103 + // SetCaptainChecker sets a callback that checks if a user is a hold captain. 104 + // Captains bypass all billing feature gates. 105 + func (m *Manager) SetCaptainChecker(fn CaptainChecker) { 106 + m.captainChecker = fn 107 + } 108 + 109 + func (m *Manager) isCaptain(userDID string) bool { 110 + return m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) 111 + } 112 + 113 + // Enabled returns true if billing is properly configured. 114 + func (m *Manager) Enabled() bool { 115 + return m.cfg != nil && m.stripeKey != "" && len(m.cfg.Tiers) > 0 116 + } 117 + 118 + // GetWebhookLimits returns webhook limits for a user based on their subscription tier. 119 + // Returns (maxWebhooks, allTriggers). Defaults to the lowest tier's limits. 120 + // Hold captains get unlimited webhooks with all triggers. 121 + func (m *Manager) GetWebhookLimits(userDID string) (int, bool) { 122 + if m.isCaptain(userDID) { 123 + return -1, true // unlimited 124 + } 125 + if !m.Enabled() { 126 + return 1, false 127 + } 128 + 129 + info, err := m.GetSubscriptionInfo(userDID) 130 + if err != nil || info == nil { 131 + return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers 132 + } 133 + 134 + rank := info.TierRank 135 + if rank >= 0 && rank < len(m.cfg.Tiers) { 136 + return m.cfg.Tiers[rank].MaxWebhooks, m.cfg.Tiers[rank].WebhookAllTriggers 137 + } 138 + 139 + return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers 140 + } 141 + 142 + // GetSupporterBadge returns the supporter badge tier name for a user based on their subscription. 143 + // Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise. 144 + // Hold captains get a "Captain" badge. 145 + func (m *Manager) GetSupporterBadge(userDID string) string { 146 + if m.isCaptain(userDID) { 147 + return "Captain" 148 + } 149 + if !m.Enabled() { 150 + return "" 151 + } 152 + 153 + info, err := m.GetSubscriptionInfo(userDID) 154 + if err != nil || info == nil { 155 + return "" 156 + } 157 + 158 + for _, tier := range info.Tiers { 159 + if tier.ID == info.CurrentTier && tier.SupporterBadge { 160 + return info.CurrentTier 161 + } 162 + } 163 + 164 + return "" 165 + } 166 + 167 + // GetSubscriptionInfo returns subscription and tier information for a user. 168 + // Hold captains see a special "Captain" tier with all features unlocked. 169 + func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) { 170 + if m.isCaptain(userDID) { 171 + return &SubscriptionInfo{ 172 + UserDID: userDID, 173 + CurrentTier: "Captain", 174 + TierRank: -1, // above all configured tiers 175 + Tiers: []TierInfo{{ 176 + ID: "Captain", 177 + Name: "Captain", 178 + Description: "Hold operator", 179 + Features: []string{"Unlimited storage", "Unlimited webhooks", "All webhook triggers", "Scan on push"}, 180 + Rank: -1, 181 + MaxWebhooks: -1, 182 + WebhookAllTriggers: true, 183 + SupporterBadge: true, 184 + IsCurrent: true, 185 + }}, 186 + }, nil 187 + } 188 + 189 + if !m.Enabled() { 190 + return nil, ErrBillingDisabled 191 + } 192 + 193 + info := &SubscriptionInfo{ 194 + UserDID: userDID, 195 + PaymentsEnabled: true, 196 + CurrentTier: m.cfg.Tiers[0].Name, // default to lowest 197 + TierRank: 0, 198 + } 199 + 200 + // Build tier list with live Stripe prices 201 + info.Tiers = make([]TierInfo, len(m.cfg.Tiers)) 202 + for i, tier := range m.cfg.Tiers { 203 + // Dynamic features: hold-derived first, then webhook limits, then static config 204 + features := m.aggregateHoldFeatures(i) 205 + features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...) 206 + if tier.SupporterBadge { 207 + features = append(features, "Supporter badge") 208 + } 209 + features = append(features, tier.Features...) 210 + info.Tiers[i] = TierInfo{ 211 + ID: tier.Name, 212 + Name: tier.Name, 213 + Description: tier.Description, 214 + Features: features, 215 + Rank: i, 216 + MaxWebhooks: tier.MaxWebhooks, 217 + WebhookAllTriggers: tier.WebhookAllTriggers, 218 + SupporterBadge: tier.SupporterBadge, 219 + } 220 + if tier.StripePriceMonthly != "" { 221 + if amount, err := m.fetchPrice(tier.StripePriceMonthly); err == nil { 222 + info.Tiers[i].PriceCentsMonthly = int(amount) 223 + } 224 + } 225 + if tier.StripePriceYearly != "" { 226 + if amount, err := m.fetchPrice(tier.StripePriceYearly); err == nil { 227 + info.Tiers[i].PriceCentsYearly = int(amount) 228 + } 229 + } 230 + } 231 + 232 + if userDID == "" { 233 + return info, nil 234 + } 235 + 236 + // Find Stripe customer for this user 237 + cust, err := m.findCustomerByDID(userDID) 238 + if err != nil { 239 + slog.Debug("No Stripe customer found", "userDID", userDID, "error", err) 240 + return info, nil 241 + } 242 + info.CustomerID = cust.ID 243 + 244 + // Find active subscription 245 + params := &stripe.SubscriptionListParams{} 246 + params.Filters.AddFilter("customer", "", cust.ID) 247 + params.Filters.AddFilter("status", "", "active") 248 + iter := subscription.List(params) 249 + 250 + for iter.Next() { 251 + sub := iter.Subscription() 252 + info.SubscriptionID = sub.ID 253 + 254 + if sub.Items != nil && len(sub.Items.Data) > 0 { 255 + priceID := sub.Items.Data[0].Price.ID 256 + tierName, tierRank := m.cfg.GetTierByPriceID(priceID) 257 + if tierName != "" { 258 + info.CurrentTier = tierName 259 + info.TierRank = tierRank 260 + } 261 + 262 + if sub.Items.Data[0].Price.Recurring != nil { 263 + switch sub.Items.Data[0].Price.Recurring.Interval { 264 + case stripe.PriceRecurringIntervalMonth: 265 + info.BillingInterval = "monthly" 266 + case stripe.PriceRecurringIntervalYear: 267 + info.BillingInterval = "yearly" 268 + } 269 + } 270 + } 271 + break 272 + } 273 + 274 + // Mark current tier 275 + for i := range info.Tiers { 276 + info.Tiers[i].IsCurrent = info.Tiers[i].ID == info.CurrentTier 277 + } 278 + 279 + return info, nil 280 + } 281 + 282 + // CreateCheckoutSession creates a Stripe checkout session for a subscription. 283 + func (m *Manager) CreateCheckoutSession(r *http.Request, userDID, userHandle string, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { 284 + if !m.Enabled() { 285 + return nil, ErrBillingDisabled 286 + } 287 + 288 + // Find the tier config 289 + rank := m.cfg.TierRank(req.Tier) 290 + if rank < 0 { 291 + return nil, fmt.Errorf("unknown tier: %s", req.Tier) 292 + } 293 + tierCfg := m.cfg.Tiers[rank] 294 + 295 + // Determine price ID: prefer monthly so Stripe upsell can offer yearly toggle, 296 + // fall back to yearly if no monthly price exists. 297 + var priceID string 298 + if req.Interval == "yearly" && tierCfg.StripePriceYearly != "" { 299 + priceID = tierCfg.StripePriceYearly 300 + } else if tierCfg.StripePriceMonthly != "" { 301 + priceID = tierCfg.StripePriceMonthly 302 + } else if tierCfg.StripePriceYearly != "" { 303 + priceID = tierCfg.StripePriceYearly 304 + } 305 + if priceID == "" { 306 + return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier) 307 + } 308 + 309 + // Get or create Stripe customer 310 + cust, err := m.getOrCreateCustomer(userDID, userHandle) 311 + if err != nil { 312 + return nil, fmt.Errorf("failed to get/create customer: %w", err) 313 + } 314 + 315 + // Build success/cancel URLs 316 + successURL := strings.ReplaceAll(m.cfg.SuccessURL, "{base_url}", m.baseURL) 317 + cancelURL := strings.ReplaceAll(m.cfg.CancelURL, "{base_url}", m.baseURL) 318 + 319 + params := &stripe.CheckoutSessionParams{ 320 + Customer: stripe.String(cust.ID), 321 + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), 322 + LineItems: []*stripe.CheckoutSessionLineItemParams{ 323 + { 324 + Price: stripe.String(priceID), 325 + Quantity: stripe.Int64(1), 326 + }, 327 + }, 328 + SuccessURL: stripe.String(successURL), 329 + CancelURL: stripe.String(cancelURL), 330 + } 331 + 332 + s, err := session.New(params) 333 + if err != nil { 334 + return nil, fmt.Errorf("failed to create checkout session: %w", err) 335 + } 336 + 337 + return &CheckoutSessionResponse{ 338 + CheckoutURL: s.URL, 339 + SessionID: s.ID, 340 + }, nil 341 + } 342 + 343 + // GetBillingPortalURL creates a Stripe billing portal session. 344 + func (m *Manager) GetBillingPortalURL(userDID, returnURL string) (*BillingPortalResponse, error) { 345 + if !m.Enabled() { 346 + return nil, ErrBillingDisabled 347 + } 348 + 349 + cust, err := m.findCustomerByDID(userDID) 350 + if err != nil { 351 + return nil, fmt.Errorf("no billing account found") 352 + } 353 + 354 + params := &stripe.BillingPortalSessionParams{ 355 + Customer: stripe.String(cust.ID), 356 + ReturnURL: stripe.String(returnURL), 357 + } 358 + 359 + s, err := portalsession.New(params) 360 + if err != nil { 361 + return nil, fmt.Errorf("failed to create portal session: %w", err) 362 + } 363 + 364 + return &BillingPortalResponse{PortalURL: s.URL}, nil 365 + } 366 + 367 + // HandleWebhook processes a Stripe webhook event. 368 + // On subscription changes, it pushes tier updates to all managed holds. 369 + func (m *Manager) HandleWebhook(r *http.Request) error { 370 + if !m.Enabled() { 371 + return ErrBillingDisabled 372 + } 373 + 374 + body, err := io.ReadAll(r.Body) 375 + if err != nil { 376 + return fmt.Errorf("failed to read webhook body: %w", err) 377 + } 378 + 379 + event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret) 380 + if err != nil { 381 + return fmt.Errorf("webhook signature verification failed: %w", err) 382 + } 383 + 384 + switch event.Type { 385 + case "checkout.session.completed": 386 + m.handleCheckoutCompleted(event) 387 + case "customer.subscription.created", 388 + "customer.subscription.updated", 389 + "customer.subscription.deleted", 390 + "customer.subscription.paused", 391 + "customer.subscription.resumed": 392 + m.handleSubscriptionChange(event) 393 + default: 394 + slog.Debug("Ignoring Stripe event", "type", event.Type) 395 + } 396 + 397 + return nil 398 + } 399 + 400 + // handleCheckoutCompleted processes a checkout.session.completed event. 401 + func (m *Manager) handleCheckoutCompleted(event stripe.Event) { 402 + var cs stripe.CheckoutSession 403 + if err := json.Unmarshal(event.Data.Raw, &cs); err != nil { 404 + slog.Error("Failed to parse checkout session", "error", err) 405 + return 406 + } 407 + 408 + slog.Info("Checkout completed", "customerID", cs.Customer.ID, "subscriptionID", cs.Subscription.ID) 409 + 410 + // The subscription.created event will handle the tier update 411 + } 412 + 413 + // handleSubscriptionChange processes subscription lifecycle events. 414 + func (m *Manager) handleSubscriptionChange(event stripe.Event) { 415 + var sub stripe.Subscription 416 + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 417 + slog.Error("Failed to parse subscription", "error", err) 418 + return 419 + } 420 + 421 + // Get user DID from customer metadata 422 + userDID := m.getCustomerDID(sub.Customer.ID) 423 + if userDID == "" { 424 + slog.Warn("No user DID found for Stripe customer", "customerID", sub.Customer.ID) 425 + return 426 + } 427 + 428 + // Determine new tier from subscription 429 + var tierName string 430 + var tierRank int 431 + 432 + switch sub.Status { 433 + case stripe.SubscriptionStatusActive: 434 + if sub.Items != nil && len(sub.Items.Data) > 0 { 435 + priceID := sub.Items.Data[0].Price.ID 436 + tierName, tierRank = m.cfg.GetTierByPriceID(priceID) 437 + } 438 + case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusPaused: 439 + // Revert to free tier (rank 0) 440 + tierName = m.cfg.Tiers[0].Name 441 + tierRank = 0 442 + default: 443 + slog.Debug("Ignoring subscription status", "status", sub.Status) 444 + return 445 + } 446 + 447 + if tierName == "" { 448 + slog.Warn("Could not resolve tier from subscription", "priceID", sub.Items.Data[0].Price.ID) 449 + return 450 + } 451 + 452 + slog.Info("Pushing tier update to managed holds", 453 + "userDID", userDID, 454 + "tierName", tierName, 455 + "tierRank", tierRank, 456 + "event", event.Type, 457 + ) 458 + 459 + // Push tier update to all managed holds 460 + go holdclient.UpdateCrewTierOnAllHolds( 461 + context.Background(), 462 + m.managedHolds, 463 + userDID, 464 + tierRank, 465 + m.privateKey, 466 + m.appviewDID, 467 + ) 468 + 469 + // Invalidate customer cache 470 + m.customerCacheMu.Lock() 471 + delete(m.customerCache, userDID) 472 + m.customerCacheMu.Unlock() 473 + } 474 + 475 + // getOrCreateCustomer finds or creates a Stripe customer for a DID. 476 + func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) { 477 + // Check cache 478 + m.customerCacheMu.RLock() 479 + if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { 480 + m.customerCacheMu.RUnlock() 481 + return cached.customer, nil 482 + } 483 + m.customerCacheMu.RUnlock() 484 + 485 + // Search Stripe 486 + cust, err := m.findCustomerByDID(userDID) 487 + if err == nil { 488 + m.cacheCustomer(userDID, cust) 489 + return cust, nil 490 + } 491 + 492 + // Create new customer 493 + params := &stripe.CustomerParams{ 494 + Params: stripe.Params{ 495 + Metadata: map[string]string{ 496 + "user_did": userDID, 497 + }, 498 + }, 499 + } 500 + if userHandle != "" { 501 + params.Name = stripe.String(userHandle) 502 + } 503 + 504 + cust, err = customer.New(params) 505 + if err != nil { 506 + return nil, fmt.Errorf("failed to create Stripe customer: %w", err) 507 + } 508 + 509 + m.cacheCustomer(userDID, cust) 510 + return cust, nil 511 + } 512 + 513 + // findCustomerByDID searches Stripe for a customer with matching DID metadata. 514 + func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) { 515 + params := &stripe.CustomerSearchParams{ 516 + SearchParams: stripe.SearchParams{ 517 + Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID), 518 + }, 519 + } 520 + 521 + iter := customer.Search(params) 522 + for iter.Next() { 523 + return iter.Customer(), nil 524 + } 525 + 526 + return nil, fmt.Errorf("customer not found for DID %s", userDID) 527 + } 528 + 529 + // getCustomerDID retrieves the user DID from a Stripe customer's metadata. 530 + func (m *Manager) getCustomerDID(customerID string) string { 531 + cust, err := customer.Get(customerID, nil) 532 + if err != nil { 533 + slog.Error("Failed to get customer", "customerID", customerID, "error", err) 534 + return "" 535 + } 536 + return cust.Metadata["user_did"] 537 + } 538 + 539 + // cacheCustomer stores a customer in the in-memory cache. 540 + func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) { 541 + m.customerCacheMu.Lock() 542 + m.customerCache[userDID] = &cachedCustomer{ 543 + customer: cust, 544 + expiresAt: time.Now().Add(customerCacheTTL), 545 + } 546 + m.customerCacheMu.Unlock() 547 + } 548 + 549 + const holdTierCacheTTL = 30 * time.Minute 550 + 551 + // RefreshHoldTiers queries all managed holds for their tier definitions and caches the results. 552 + // It runs once immediately (with retries for holds that aren't ready yet) and then 553 + // periodically in the background. 554 + // Safe to call from a goroutine. 555 + func (m *Manager) RefreshHoldTiers() { 556 + if !m.Enabled() || len(m.managedHolds) == 0 { 557 + return 558 + } 559 + 560 + // On startup, retry a few times with backoff in case holds aren't ready yet. 561 + // This is common in docker-compose where appview starts before the hold. 562 + const maxRetries = 5 563 + const initialDelay = 3 * time.Second 564 + 565 + for attempt := range maxRetries { 566 + m.refreshHoldTiersOnce() 567 + 568 + // Check if all managed holds are cached 569 + m.holdTierCacheMu.RLock() 570 + allCached := len(m.holdTierCache) == len(m.managedHolds) 571 + m.holdTierCacheMu.RUnlock() 572 + 573 + if allCached { 574 + break 575 + } 576 + 577 + if attempt < maxRetries-1 { 578 + delay := initialDelay * time.Duration(1<<attempt) // 3s, 6s, 12s, 24s 579 + slog.Info("Some managed holds not yet reachable, retrying", 580 + "attempt", attempt+1, "maxRetries", maxRetries, "retryIn", delay) 581 + time.Sleep(delay) 582 + } 583 + } 584 + 585 + ticker := time.NewTicker(holdTierCacheTTL) 586 + defer ticker.Stop() 587 + for range ticker.C { 588 + m.refreshHoldTiersOnce() 589 + } 590 + } 591 + 592 + func (m *Manager) refreshHoldTiersOnce() { 593 + for _, holdDID := range m.managedHolds { 594 + resp, err := holdclient.ListTiers(context.Background(), holdDID) 595 + if err != nil { 596 + slog.Warn("Failed to fetch tiers from hold", "holdDID", holdDID, "error", err) 597 + continue 598 + } 599 + 600 + m.holdTierCacheMu.Lock() 601 + m.holdTierCache[holdDID] = &cachedHoldTiers{ 602 + tiers: resp.Tiers, 603 + expiresAt: time.Now().Add(holdTierCacheTTL), 604 + } 605 + m.holdTierCacheMu.Unlock() 606 + 607 + slog.Debug("Cached tier data from hold", "holdDID", holdDID, "tierCount", len(resp.Tiers)) 608 + } 609 + } 610 + 611 + // aggregateHoldFeatures generates dynamic feature strings for a tier rank 612 + // by aggregating data from all cached managed holds. 613 + // Returns nil if no hold data is available. 614 + func (m *Manager) aggregateHoldFeatures(rank int) []string { 615 + m.holdTierCacheMu.RLock() 616 + defer m.holdTierCacheMu.RUnlock() 617 + 618 + if len(m.holdTierCache) == 0 { 619 + return nil 620 + } 621 + 622 + var ( 623 + minQuota int64 = -1 624 + maxQuota int64 625 + scanCount int 626 + totalHolds int 627 + ) 628 + 629 + for _, cached := range m.holdTierCache { 630 + if time.Now().After(cached.expiresAt) { 631 + continue 632 + } 633 + if rank >= len(cached.tiers) { 634 + continue 635 + } 636 + totalHolds++ 637 + tier := cached.tiers[rank] 638 + 639 + if minQuota < 0 || tier.QuotaBytes < minQuota { 640 + minQuota = tier.QuotaBytes 641 + } 642 + if tier.QuotaBytes > maxQuota { 643 + maxQuota = tier.QuotaBytes 644 + } 645 + if tier.ScanOnPush { 646 + scanCount++ 647 + } 648 + } 649 + 650 + if totalHolds == 0 { 651 + return nil 652 + } 653 + 654 + var features []string 655 + 656 + // Storage feature 657 + if minQuota == maxQuota { 658 + features = append(features, formatBytes(minQuota)+" storage") 659 + } else { 660 + features = append(features, formatBytes(minQuota)+"-"+formatBytes(maxQuota)+" storage") 661 + } 662 + 663 + // Scan on push feature 664 + if scanCount == totalHolds { 665 + features = append(features, "Scan on push") 666 + } else if scanCount*2 >= totalHolds { 667 + features = append(features, "Scan on push (most regions)") 668 + } else if scanCount > 0 { 669 + features = append(features, "Scan on push (some regions)") 670 + } 671 + 672 + return features 673 + } 674 + 675 + // webhookFeatures generates feature bullet strings for webhook limits. 676 + func webhookFeatures(maxWebhooks int, allTriggers bool) []string { 677 + var features []string 678 + switch { 679 + case maxWebhooks < 0: 680 + features = append(features, "Unlimited webhooks") 681 + case maxWebhooks == 1: 682 + features = append(features, "1 webhook") 683 + case maxWebhooks > 1: 684 + features = append(features, fmt.Sprintf("%d webhooks", maxWebhooks)) 685 + } 686 + if allTriggers { 687 + features = append(features, "All webhook triggers") 688 + } 689 + return features 690 + } 691 + 692 + // formatBytes formats bytes as a human-readable string (e.g. "5.0 GB"). 693 + func formatBytes(b int64) string { 694 + const unit = 1024 695 + if b < unit { 696 + return fmt.Sprintf("%d B", b) 697 + } 698 + div, exp := int64(unit), 0 699 + for n := b / unit; n >= unit; n /= unit { 700 + div *= unit 701 + exp++ 702 + } 703 + units := []string{"KB", "MB", "GB", "TB", "PB"} 704 + return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp]) 705 + } 706 + 707 + // fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache. 708 + func (m *Manager) fetchPrice(priceID string) (int64, error) { 709 + m.priceCacheMu.RLock() 710 + if cached, ok := m.priceCache[priceID]; ok && time.Now().Before(cached.expiresAt) { 711 + m.priceCacheMu.RUnlock() 712 + return cached.unitAmount, nil 713 + } 714 + m.priceCacheMu.RUnlock() 715 + 716 + p, err := price.Get(priceID, nil) 717 + if err != nil { 718 + slog.Warn("Failed to fetch Stripe price", "priceID", priceID, "error", err) 719 + return 0, err 720 + } 721 + 722 + m.priceCacheMu.Lock() 723 + m.priceCache[priceID] = &cachedPrice{ 724 + unitAmount: p.UnitAmount, 725 + expiresAt: time.Now().Add(priceCacheTTL), 726 + } 727 + m.priceCacheMu.Unlock() 728 + 729 + return p.UnitAmount, nil 730 + }
+72
pkg/billing/billing_stub.go
··· 1 + //go:build !billing 2 + 3 + package billing 4 + 5 + import ( 6 + "net/http" 7 + 8 + "github.com/bluesky-social/indigo/atproto/atcrypto" 9 + "github.com/go-chi/chi/v5" 10 + ) 11 + 12 + // Manager is a no-op billing manager when billing is not compiled in. 13 + type Manager struct { 14 + captainChecker CaptainChecker 15 + } 16 + 17 + // New creates a no-op billing manager. 18 + func New(_ *Config, _ *atcrypto.PrivateKeyP256, _ string, _ []string, _ string) *Manager { 19 + return &Manager{} 20 + } 21 + 22 + // SetCaptainChecker sets a callback that checks if a user is a hold captain. 23 + func (m *Manager) SetCaptainChecker(fn CaptainChecker) { 24 + m.captainChecker = fn 25 + } 26 + 27 + // Enabled returns false when billing is not compiled in. 28 + func (m *Manager) Enabled() bool { return false } 29 + 30 + // GetWebhookLimits returns default limits when billing is not compiled in. 31 + // Hold captains get unlimited webhooks with all triggers. 32 + func (m *Manager) GetWebhookLimits(userDID string) (int, bool) { 33 + if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) { 34 + return -1, true 35 + } 36 + return 1, false 37 + } 38 + 39 + // GetSubscriptionInfo returns an error when billing is not compiled in. 40 + func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) { 41 + return nil, ErrBillingDisabled 42 + } 43 + 44 + // CreateCheckoutSession returns an error when billing is not compiled in. 45 + func (m *Manager) CreateCheckoutSession(_ *http.Request, _, _ string, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { 46 + return nil, ErrBillingDisabled 47 + } 48 + 49 + // GetBillingPortalURL returns an error when billing is not compiled in. 50 + func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) { 51 + return nil, ErrBillingDisabled 52 + } 53 + 54 + // HandleWebhook returns an error when billing is not compiled in. 55 + func (m *Manager) HandleWebhook(_ *http.Request) error { 56 + return ErrBillingDisabled 57 + } 58 + 59 + // GetSupporterBadge returns empty string when billing is not compiled in. 60 + // Hold captains get a "Captain" badge. 61 + func (m *Manager) GetSupporterBadge(userDID string) string { 62 + if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) { 63 + return "Captain" 64 + } 65 + return "" 66 + } 67 + 68 + // RegisterRoutes is a no-op when billing is not compiled in. 69 + func (m *Manager) RegisterRoutes(_ chi.Router) {} 70 + 71 + // RefreshHoldTiers is a no-op when billing is not compiled in. 72 + func (m *Manager) RefreshHoldTiers() {}
+83
pkg/billing/config.go
··· 1 + package billing 2 + 3 + // Config holds appview billing/Stripe configuration. 4 + // Parsed from the appview config YAML's billing section. 5 + type Config struct { 6 + // Stripe secret key (sk_test_... or sk_live_...). 7 + // Can also be set via STRIPE_SECRET_KEY env var (takes precedence over config). 8 + // Billing is enabled automatically when this key is set (requires -tags billing build). 9 + StripeSecretKey string `yaml:"stripe_secret_key" comment:"Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set."` 10 + 11 + // Stripe webhook signing secret (whsec_...). 12 + // Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence over config). 13 + WebhookSecret string `yaml:"webhook_secret" comment:"Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence)."` 14 + 15 + // Currency code for Stripe checkout (e.g. "usd"). 16 + Currency string `yaml:"currency" comment:"ISO 4217 currency code (e.g. \"usd\")."` 17 + 18 + // URL to redirect after successful checkout. {base_url} is replaced at runtime. 19 + SuccessURL string `yaml:"success_url" comment:"Redirect URL after successful checkout. Use {base_url} placeholder."` 20 + 21 + // URL to redirect after cancelled checkout. {base_url} is replaced at runtime. 22 + CancelURL string `yaml:"cancel_url" comment:"Redirect URL after cancelled checkout. Use {base_url} placeholder."` 23 + 24 + // Subscription tiers with Stripe price IDs. 25 + Tiers []BillingTierConfig `yaml:"tiers" comment:"Subscription tiers ordered by rank (lowest to highest)."` 26 + 27 + // Whether hold owners get a supporter badge on their profile. 28 + OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on hold owner profiles."` 29 + } 30 + 31 + // BillingTierConfig represents a single tier with optional Stripe pricing. 32 + type BillingTierConfig struct { 33 + // Tier name (matches hold quota tier names for rank mapping). 34 + Name string `yaml:"name" comment:"Tier name. Position in list determines rank (0-based)."` 35 + 36 + // Short description shown on the plan card. 37 + Description string `yaml:"description,omitempty" comment:"Short description shown on the plan card."` 38 + 39 + // List of features included in this tier (rendered as bullet points). 40 + Features []string `yaml:"features,omitempty" comment:"List of features included in this tier."` 41 + 42 + // Stripe price ID for monthly billing. Empty = free tier. 43 + StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty" comment:"Stripe price ID for monthly billing. Empty = free tier."` 44 + 45 + // Stripe price ID for yearly billing. 46 + StripePriceYearly string `yaml:"stripe_price_yearly,omitempty" comment:"Stripe price ID for yearly billing."` 47 + 48 + // Maximum number of webhooks for this tier (-1 = unlimited). 49 + MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhooks for this tier (-1 = unlimited)."` 50 + 51 + // Whether all webhook trigger types are available (not just first-scan). 52 + WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."` 53 + 54 + // Whether this tier earns a supporter badge on user profiles. 55 + SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."` 56 + } 57 + 58 + // GetTierByPriceID finds the tier that contains the given Stripe price ID. 59 + // Returns the tier name and rank, or empty string and -1 if not found. 60 + func (c *Config) GetTierByPriceID(priceID string) (string, int) { 61 + if c == nil || priceID == "" { 62 + return "", -1 63 + } 64 + for i, tier := range c.Tiers { 65 + if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID { 66 + return tier.Name, i 67 + } 68 + } 69 + return "", -1 70 + } 71 + 72 + // TierRank returns the 0-based rank of a tier by name, or -1 if not found. 73 + func (c *Config) TierRank(name string) int { 74 + if c == nil { 75 + return -1 76 + } 77 + for i, tier := range c.Tiers { 78 + if tier.Name == name { 79 + return i 80 + } 81 + } 82 + return -1 83 + }
+57
pkg/billing/handlers.go
··· 1 + //go:build billing 2 + 3 + package billing 4 + 5 + import ( 6 + "encoding/json" 7 + "log/slog" 8 + "net/http" 9 + 10 + "github.com/go-chi/chi/v5" 11 + ) 12 + 13 + // RegisterRoutes registers billing HTTP routes on the router. 14 + // These routes handle subscription management and Stripe webhooks. 15 + func (m *Manager) RegisterRoutes(r chi.Router) { 16 + if !m.Enabled() { 17 + slog.Info("Billing routes disabled (not configured)") 18 + return 19 + } 20 + 21 + slog.Info("Registering billing routes") 22 + 23 + // Stripe webhook (public, verified by Stripe signature) 24 + r.Post("/api/stripe/webhook", m.handleStripeWebhook) 25 + } 26 + 27 + // handleStripeWebhook processes incoming Stripe webhook events. 28 + func (m *Manager) handleStripeWebhook(w http.ResponseWriter, r *http.Request) { 29 + if err := m.HandleWebhook(r); err != nil { 30 + slog.Error("Stripe webhook error", "error", err) 31 + http.Error(w, err.Error(), http.StatusBadRequest) 32 + return 33 + } 34 + 35 + w.WriteHeader(http.StatusOK) 36 + if _, err := w.Write([]byte(`{"received": true}`)); err != nil { 37 + slog.Error("Failed to write webhook response", "error", err) 38 + } 39 + } 40 + 41 + // HandleGetSubscription is an HTTP handler that returns subscription info as JSON. 42 + // Used by the settings page HTMX endpoint. 43 + func (m *Manager) HandleGetSubscription(w http.ResponseWriter, r *http.Request, userDID string) { 44 + info, err := m.GetSubscriptionInfo(userDID) 45 + if err != nil { 46 + w.WriteHeader(http.StatusOK) 47 + if _, writeErr := w.Write([]byte("")); writeErr != nil { 48 + slog.Error("Failed to write empty response", "error", writeErr) 49 + } 50 + return 51 + } 52 + 53 + w.Header().Set("Content-Type", "application/json") 54 + if err := json.NewEncoder(w).Encode(info); err != nil { 55 + slog.Error("Failed to encode subscription info", "error", err) 56 + } 57 + }
+59
pkg/billing/types.go
··· 1 + // Package billing provides optional Stripe billing integration for the appview. 2 + // Build with -tags billing to enable Stripe integration. 3 + // Without the tag, no-op stubs are compiled instead. 4 + package billing 5 + 6 + import "errors" 7 + 8 + // ErrBillingDisabled is returned when billing operations are attempted 9 + // but billing is not compiled in or not configured. 10 + var ErrBillingDisabled = errors.New("billing not enabled") 11 + 12 + // CaptainChecker returns true if a user DID is the captain (owner) of a managed hold. 13 + // Used to bypass billing feature gates for hold operators. 14 + type CaptainChecker func(userDID string) bool 15 + 16 + // SubscriptionInfo contains subscription information for a user. 17 + type SubscriptionInfo struct { 18 + UserDID string `json:"userDid"` 19 + CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default) 20 + TierRank int `json:"tierRank"` // 0-based rank index 21 + PaymentsEnabled bool `json:"paymentsEnabled"` // whether billing is active 22 + Tiers []TierInfo `json:"tiers"` // available tiers with pricing 23 + SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active 24 + CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists 25 + BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly" 26 + } 27 + 28 + // TierInfo describes a single tier available for subscription. 29 + type TierInfo struct { 30 + ID string `json:"id"` // tier key 31 + Name string `json:"name"` // display name 32 + Description string `json:"description,omitempty"` // short description for the plan card 33 + Features []string `json:"features,omitempty"` // feature bullet points 34 + Rank int `json:"rank"` // 0-based rank 35 + PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free) 36 + PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available) 37 + MaxWebhooks int `json:"maxWebhooks"` // max webhooks (-1 = unlimited) 38 + WebhookAllTriggers bool `json:"webhookAllTriggers,omitempty"` // all trigger types available 39 + SupporterBadge bool `json:"supporterBadge,omitempty"` // earns supporter badge on profile 40 + IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier 41 + } 42 + 43 + // CheckoutSessionRequest is the request to create a Stripe checkout session. 44 + type CheckoutSessionRequest struct { 45 + Tier string `json:"tier"` 46 + Interval string `json:"interval,omitempty"` // "monthly" or "yearly" 47 + ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout 48 + } 49 + 50 + // CheckoutSessionResponse is the response with the Stripe checkout URL. 51 + type CheckoutSessionResponse struct { 52 + CheckoutURL string `json:"checkoutUrl"` 53 + SessionID string `json:"sessionId"` 54 + } 55 + 56 + // BillingPortalResponse is the response with the Stripe billing portal URL. 57 + type BillingPortalResponse struct { 58 + PortalURL string `json:"portalUrl"` 59 + }
+1 -1
pkg/hold/admin/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 - <symbol id="badge-check" viewBox="0 0 24 24"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol> 10 9 <symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol> 11 10 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 12 11 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> ··· 19 18 <symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol> 20 19 <symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol> 21 20 <symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol> 21 + <symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol> 22 22 <symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol> 23 23 <symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol> 24 24 <symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
-565
pkg/hold/billing/billing.go
··· 1 - //go:build billing 2 - 3 - package billing 4 - 5 - import ( 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "io" 10 - "log/slog" 11 - "net/http" 12 - "os" 13 - "sort" 14 - "strings" 15 - "sync" 16 - "time" 17 - 18 - "github.com/stripe/stripe-go/v84" 19 - portalsession "github.com/stripe/stripe-go/v84/billingportal/session" 20 - "github.com/stripe/stripe-go/v84/checkout/session" 21 - "github.com/stripe/stripe-go/v84/customer" 22 - "github.com/stripe/stripe-go/v84/price" 23 - "github.com/stripe/stripe-go/v84/subscription" 24 - "github.com/stripe/stripe-go/v84/webhook" 25 - 26 - "atcr.io/pkg/hold/quota" 27 - ) 28 - 29 - // Manager handles Stripe billing integration. 30 - type Manager struct { 31 - quotaMgr *quota.Manager 32 - billingCfg *BillingConfig 33 - holdPublicURL string 34 - stripeKey string 35 - webhookSecret string 36 - publishableKey string 37 - 38 - // In-memory cache for customer lookups (DID -> customer) 39 - customerCache map[string]*cachedCustomer 40 - customerCacheMu sync.RWMutex 41 - } 42 - 43 - type cachedCustomer struct { 44 - customer *stripe.Customer 45 - expiresAt time.Time 46 - } 47 - 48 - const customerCacheTTL = 10 * time.Minute 49 - 50 - // New creates a new billing manager with Stripe integration. 51 - // configPath is the path to the hold config YAML file (for billing config parsing). 52 - func New(quotaMgr *quota.Manager, holdPublicURL string, configPath string) *Manager { 53 - stripeKey := os.Getenv("STRIPE_SECRET_KEY") 54 - if stripeKey != "" { 55 - stripe.Key = stripeKey 56 - } 57 - 58 - billingCfg, err := LoadBillingConfig(configPath) 59 - if err != nil { 60 - slog.Warn("Failed to load billing config", "error", err) 61 - } 62 - 63 - // Validate billing tier names against quota tiers 64 - if billingCfg != nil && billingCfg.Enabled { 65 - for tierName := range billingCfg.Tiers { 66 - if quotaMgr.GetTierLimit(tierName) == nil && tierName != quotaMgr.GetDefaultTier() { 67 - slog.Warn("Billing tier has no matching quota tier", "tier", tierName) 68 - } 69 - } 70 - } 71 - 72 - return &Manager{ 73 - quotaMgr: quotaMgr, 74 - billingCfg: billingCfg, 75 - holdPublicURL: holdPublicURL, 76 - stripeKey: stripeKey, 77 - webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), 78 - publishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), 79 - customerCache: make(map[string]*cachedCustomer), 80 - } 81 - } 82 - 83 - // Enabled returns true if billing is properly configured. 84 - func (m *Manager) Enabled() bool { 85 - return m.billingCfg != nil && m.billingCfg.Enabled && m.stripeKey != "" 86 - } 87 - 88 - // GetSubscriptionInfo returns subscription and quota information for a user. 89 - func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) { 90 - if !m.Enabled() { 91 - return nil, ErrBillingDisabled 92 - } 93 - 94 - info := &SubscriptionInfo{ 95 - UserDID: userDID, 96 - PaymentsEnabled: true, 97 - Tiers: m.buildTierList(userDID), 98 - } 99 - 100 - // Try to find existing customer 101 - cust, err := m.findCustomerByDID(userDID) 102 - if err != nil { 103 - slog.Debug("No Stripe customer found for user", "userDid", userDID) 104 - } else if cust != nil { 105 - info.CustomerID = cust.ID 106 - 107 - // Get active subscription if any (check all nil pointers) 108 - if cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 { 109 - sub := cust.Subscriptions.Data[0] 110 - info.SubscriptionID = sub.ID 111 - 112 - // Safely access subscription items 113 - if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0].Price != nil { 114 - info.CurrentTier = m.billingCfg.GetTierByPriceID(sub.Items.Data[0].Price.ID) 115 - 116 - if sub.Items.Data[0].Price.Recurring != nil { 117 - switch sub.Items.Data[0].Price.Recurring.Interval { 118 - case stripe.PriceRecurringIntervalMonth: 119 - info.BillingInterval = "monthly" 120 - case stripe.PriceRecurringIntervalYear: 121 - info.BillingInterval = "yearly" 122 - } 123 - } 124 - } 125 - } 126 - } 127 - 128 - // If no subscription, use default tier 129 - if info.CurrentTier == "" { 130 - info.CurrentTier = m.quotaMgr.GetDefaultTier() 131 - } 132 - 133 - // Get quota limit for current tier 134 - limit := m.quotaMgr.GetTierLimit(info.CurrentTier) 135 - info.CurrentLimit = limit 136 - 137 - // Mark current tier in tier list 138 - for i := range info.Tiers { 139 - if info.Tiers[i].ID == info.CurrentTier { 140 - info.Tiers[i].IsCurrent = true 141 - } 142 - } 143 - 144 - return info, nil 145 - } 146 - 147 - // buildTierList creates the list of available tiers by merging quota limits 148 - // from the quota manager with billing metadata from the billing config. 149 - func (m *Manager) buildTierList(userDID string) []TierInfo { 150 - quotaTiers := m.quotaMgr.ListTiers() 151 - if len(quotaTiers) == 0 { 152 - return nil 153 - } 154 - 155 - result := make([]TierInfo, 0, len(quotaTiers)) 156 - for _, qt := range quotaTiers { 157 - var quotaBytes int64 158 - if qt.Limit != nil { 159 - quotaBytes = *qt.Limit 160 - } 161 - 162 - // Capitalize tier ID for display name (e.g., "swabbie" -> "Swabbie") 163 - name := strings.ToUpper(qt.Key[:1]) + qt.Key[1:] 164 - 165 - tier := TierInfo{ 166 - ID: qt.Key, 167 - Name: name, 168 - QuotaBytes: quotaBytes, 169 - QuotaFormatted: quota.FormatHumanBytes(quotaBytes), 170 - } 171 - 172 - // Merge billing metadata if available 173 - if bt := m.billingCfg.GetTierPricing(qt.Key); bt != nil { 174 - tier.Description = bt.Description 175 - 176 - // Fetch actual prices from Stripe 177 - if bt.StripePriceMonthly != "" { 178 - if p, err := price.Get(bt.StripePriceMonthly, nil); err == nil && p != nil { 179 - tier.PriceCentsMonthly = int(p.UnitAmount) 180 - } else { 181 - slog.Debug("Failed to fetch monthly price", "priceId", bt.StripePriceMonthly, "error", err) 182 - tier.PriceCentsMonthly = -1 183 - } 184 - } 185 - if bt.StripePriceYearly != "" { 186 - if p, err := price.Get(bt.StripePriceYearly, nil); err == nil && p != nil { 187 - tier.PriceCentsYearly = int(p.UnitAmount) 188 - } else { 189 - slog.Debug("Failed to fetch yearly price", "priceId", bt.StripePriceYearly, "error", err) 190 - tier.PriceCentsYearly = -1 191 - } 192 - } 193 - } 194 - 195 - result = append(result, tier) 196 - } 197 - 198 - // Sort tiers by quota size (ascending) 199 - sort.Slice(result, func(i, j int) bool { 200 - return result[i].QuotaBytes < result[j].QuotaBytes 201 - }) 202 - 203 - return result 204 - } 205 - 206 - // CreateCheckoutSession creates a Stripe checkout session for subscription. 207 - func (m *Manager) CreateCheckoutSession(r *http.Request, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { 208 - if !m.Enabled() { 209 - return nil, ErrBillingDisabled 210 - } 211 - 212 - // Get user DID from request context (set by auth middleware) 213 - userDID := r.Header.Get("X-User-DID") 214 - if userDID == "" { 215 - return nil, errors.New("user not authenticated") 216 - } 217 - 218 - // Get tier config 219 - tierCfg := m.billingCfg.GetTierPricing(req.Tier) 220 - if tierCfg == nil { 221 - return nil, fmt.Errorf("tier not found: %s", req.Tier) 222 - } 223 - 224 - // Determine price ID - prefer requested interval, fall back to what's available 225 - var priceID string 226 - switch req.Interval { 227 - case "monthly": 228 - priceID = tierCfg.StripePriceMonthly 229 - case "yearly": 230 - priceID = tierCfg.StripePriceYearly 231 - default: 232 - // No interval specified - prefer monthly, fall back to yearly 233 - if tierCfg.StripePriceMonthly != "" { 234 - priceID = tierCfg.StripePriceMonthly 235 - } else { 236 - priceID = tierCfg.StripePriceYearly 237 - } 238 - } 239 - 240 - if priceID == "" { 241 - return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier) 242 - } 243 - 244 - // Get or create customer 245 - cust, err := m.getOrCreateCustomer(userDID) 246 - if err != nil { 247 - return nil, fmt.Errorf("failed to get/create customer: %w", err) 248 - } 249 - 250 - // Build success/cancel URLs 251 - successURL := strings.ReplaceAll(m.billingCfg.SuccessURL, "{hold_url}", m.holdPublicURL) 252 - cancelURL := strings.ReplaceAll(m.billingCfg.CancelURL, "{hold_url}", m.holdPublicURL) 253 - 254 - if req.ReturnURL != "" { 255 - successURL = req.ReturnURL + "?success=true" 256 - cancelURL = req.ReturnURL + "?cancelled=true" 257 - } 258 - 259 - // Create checkout session 260 - params := &stripe.CheckoutSessionParams{ 261 - Customer: stripe.String(cust.ID), 262 - Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), 263 - LineItems: []*stripe.CheckoutSessionLineItemParams{ 264 - { 265 - Price: stripe.String(priceID), 266 - Quantity: stripe.Int64(1), 267 - }, 268 - }, 269 - SuccessURL: stripe.String(successURL), 270 - CancelURL: stripe.String(cancelURL), 271 - } 272 - 273 - sess, err := session.New(params) 274 - if err != nil { 275 - return nil, fmt.Errorf("failed to create checkout session: %w", err) 276 - } 277 - 278 - return &CheckoutSessionResponse{ 279 - CheckoutURL: sess.URL, 280 - SessionID: sess.ID, 281 - }, nil 282 - } 283 - 284 - // GetBillingPortalURL returns a URL to the Stripe billing portal. 285 - func (m *Manager) GetBillingPortalURL(userDID string, returnURL string) (*BillingPortalResponse, error) { 286 - if !m.Enabled() { 287 - return nil, ErrBillingDisabled 288 - } 289 - 290 - // Find existing customer 291 - cust, err := m.findCustomerByDID(userDID) 292 - if err != nil || cust == nil { 293 - return nil, errors.New("no billing account found") 294 - } 295 - 296 - if returnURL == "" { 297 - returnURL = m.holdPublicURL 298 - } 299 - 300 - params := &stripe.BillingPortalSessionParams{ 301 - Customer: stripe.String(cust.ID), 302 - ReturnURL: stripe.String(returnURL), 303 - } 304 - 305 - sess, err := portalsession.New(params) 306 - if err != nil { 307 - return nil, fmt.Errorf("failed to create portal session: %w", err) 308 - } 309 - 310 - return &BillingPortalResponse{ 311 - PortalURL: sess.URL, 312 - }, nil 313 - } 314 - 315 - // HandleWebhook processes a Stripe webhook event. 316 - func (m *Manager) HandleWebhook(r *http.Request) (*WebhookEvent, error) { 317 - if !m.Enabled() { 318 - return nil, ErrBillingDisabled 319 - } 320 - 321 - body, err := io.ReadAll(r.Body) 322 - if err != nil { 323 - return nil, fmt.Errorf("failed to read request body: %w", err) 324 - } 325 - 326 - // Verify webhook signature 327 - event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret) 328 - if err != nil { 329 - return nil, fmt.Errorf("failed to verify webhook signature: %w", err) 330 - } 331 - 332 - result := &WebhookEvent{ 333 - Type: string(event.Type), 334 - } 335 - 336 - switch event.Type { 337 - case "checkout.session.completed": 338 - var sess stripe.CheckoutSession 339 - if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { 340 - return nil, fmt.Errorf("failed to parse checkout session: %w", err) 341 - } 342 - 343 - result.CustomerID = sess.Customer.ID 344 - result.SubscriptionID = sess.Subscription.ID 345 - result.Status = "active" 346 - 347 - // Fetch customer to get DID from metadata 348 - result.UserDID = m.getCustomerDID(sess.Customer.ID) 349 - 350 - // Get subscription to find the price/tier 351 - if sess.Subscription != nil && sess.Subscription.ID != "" { 352 - if sub, err := m.getSubscription(sess.Subscription.ID); err == nil && sub != nil { 353 - if len(sub.Items.Data) > 0 { 354 - result.PriceID = sub.Items.Data[0].Price.ID 355 - result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) 356 - } 357 - } 358 - } 359 - 360 - if result.UserDID != "" && result.NewTier != "" { 361 - slog.Info("Checkout completed", 362 - "userDid", result.UserDID, 363 - "tier", result.NewTier, 364 - "subscriptionId", result.SubscriptionID, 365 - ) 366 - } 367 - 368 - case "customer.subscription.created", "customer.subscription.updated": 369 - var sub stripe.Subscription 370 - if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 371 - return nil, fmt.Errorf("failed to parse subscription: %w", err) 372 - } 373 - 374 - result.SubscriptionID = sub.ID 375 - result.CustomerID = sub.Customer.ID 376 - result.Status = string(sub.Status) 377 - 378 - if len(sub.Items.Data) > 0 { 379 - result.PriceID = sub.Items.Data[0].Price.ID 380 - result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) 381 - } 382 - 383 - // Fetch customer to get DID from metadata (webhook doesn't include expanded customer) 384 - result.UserDID = m.getCustomerDID(sub.Customer.ID) 385 - 386 - // If we have user DID and new tier, this signals that crew tier should be updated 387 - if result.UserDID != "" && result.NewTier != "" && sub.Status == stripe.SubscriptionStatusActive { 388 - slog.Info("Subscription activated", 389 - "userDid", result.UserDID, 390 - "tier", result.NewTier, 391 - "subscriptionId", result.SubscriptionID, 392 - ) 393 - } 394 - 395 - case "customer.subscription.deleted", "customer.subscription.paused": 396 - var sub stripe.Subscription 397 - if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 398 - return nil, fmt.Errorf("failed to parse subscription: %w", err) 399 - } 400 - 401 - result.SubscriptionID = sub.ID 402 - result.CustomerID = sub.Customer.ID 403 - if event.Type == "customer.subscription.deleted" { 404 - result.Status = "cancelled" 405 - } else { 406 - result.Status = "paused" 407 - } 408 - 409 - // Fetch customer to get DID from metadata 410 - result.UserDID = m.getCustomerDID(sub.Customer.ID) 411 - 412 - // Set tier to default (downgrade on cancellation/pause) 413 - result.NewTier = m.quotaMgr.GetDefaultTier() 414 - 415 - if result.UserDID != "" { 416 - slog.Info("Subscription inactive, downgrading to default tier", 417 - "userDid", result.UserDID, 418 - "tier", result.NewTier, 419 - "status", result.Status, 420 - ) 421 - } 422 - 423 - case "customer.subscription.resumed": 424 - var sub stripe.Subscription 425 - if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 426 - return nil, fmt.Errorf("failed to parse subscription: %w", err) 427 - } 428 - 429 - result.SubscriptionID = sub.ID 430 - result.CustomerID = sub.Customer.ID 431 - result.Status = "active" 432 - 433 - if len(sub.Items.Data) > 0 { 434 - result.PriceID = sub.Items.Data[0].Price.ID 435 - result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) 436 - } 437 - 438 - // Fetch customer to get DID from metadata 439 - result.UserDID = m.getCustomerDID(sub.Customer.ID) 440 - 441 - if result.UserDID != "" && result.NewTier != "" { 442 - slog.Info("Subscription resumed, restoring tier", 443 - "userDid", result.UserDID, 444 - "tier", result.NewTier, 445 - ) 446 - } 447 - } 448 - 449 - return result, nil 450 - } 451 - 452 - // getOrCreateCustomer finds or creates a Stripe customer for the given DID. 453 - func (m *Manager) getOrCreateCustomer(userDID string) (*stripe.Customer, error) { 454 - // Check cache first 455 - m.customerCacheMu.RLock() 456 - if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { 457 - m.customerCacheMu.RUnlock() 458 - return cached.customer, nil 459 - } 460 - m.customerCacheMu.RUnlock() 461 - 462 - // Try to find existing customer 463 - cust, err := m.findCustomerByDID(userDID) 464 - if err == nil && cust != nil { 465 - m.cacheCustomer(userDID, cust) 466 - return cust, nil 467 - } 468 - 469 - // Create new customer 470 - params := &stripe.CustomerParams{ 471 - Metadata: map[string]string{ 472 - "user_did": userDID, 473 - "hold_did": m.holdPublicURL, // Not actually a DID but useful for tracking 474 - }, 475 - } 476 - 477 - cust, err = customer.New(params) 478 - if err != nil { 479 - return nil, fmt.Errorf("failed to create customer: %w", err) 480 - } 481 - 482 - m.cacheCustomer(userDID, cust) 483 - return cust, nil 484 - } 485 - 486 - // findCustomerByDID searches Stripe for a customer with the given DID in metadata. 487 - func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) { 488 - // Check cache first 489 - m.customerCacheMu.RLock() 490 - if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { 491 - m.customerCacheMu.RUnlock() 492 - return cached.customer, nil 493 - } 494 - m.customerCacheMu.RUnlock() 495 - 496 - // Search Stripe by metadata 497 - params := &stripe.CustomerSearchParams{ 498 - SearchParams: stripe.SearchParams{ 499 - Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID), 500 - }, 501 - } 502 - params.AddExpand("data.subscriptions") 503 - 504 - iter := customer.Search(params) 505 - if iter.Next() { 506 - cust := iter.Customer() 507 - m.cacheCustomer(userDID, cust) 508 - return cust, nil 509 - } 510 - 511 - if err := iter.Err(); err != nil { 512 - return nil, err 513 - } 514 - 515 - return nil, nil // Not found 516 - } 517 - 518 - // cacheCustomer adds a customer to the in-memory cache. 519 - func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) { 520 - m.customerCacheMu.Lock() 521 - defer m.customerCacheMu.Unlock() 522 - 523 - m.customerCache[userDID] = &cachedCustomer{ 524 - customer: cust, 525 - expiresAt: time.Now().Add(customerCacheTTL), 526 - } 527 - } 528 - 529 - // InvalidateCustomerCache removes a customer from the cache. 530 - func (m *Manager) InvalidateCustomerCache(userDID string) { 531 - m.customerCacheMu.Lock() 532 - defer m.customerCacheMu.Unlock() 533 - 534 - delete(m.customerCache, userDID) 535 - } 536 - 537 - // getCustomerDID fetches a customer by ID and returns the user_did from metadata. 538 - func (m *Manager) getCustomerDID(customerID string) string { 539 - if customerID == "" { 540 - return "" 541 - } 542 - 543 - cust, err := customer.Get(customerID, nil) 544 - if err != nil { 545 - slog.Debug("Failed to fetch customer", "customerId", customerID, "error", err) 546 - return "" 547 - } 548 - 549 - if cust.Metadata != nil { 550 - return cust.Metadata["user_did"] 551 - } 552 - return "" 553 - } 554 - 555 - // getSubscription fetches a subscription by ID. 556 - func (m *Manager) getSubscription(subscriptionID string) (*stripe.Subscription, error) { 557 - if subscriptionID == "" { 558 - return nil, nil 559 - } 560 - 561 - params := &stripe.SubscriptionParams{} 562 - params.AddExpand("items.data.price") 563 - 564 - return subscription.Get(subscriptionID, params) 565 - }
-60
pkg/hold/billing/billing_stub.go
··· 1 - //go:build !billing 2 - 3 - package billing 4 - 5 - import ( 6 - "net/http" 7 - 8 - "github.com/go-chi/chi/v5" 9 - 10 - "atcr.io/pkg/hold/pds" 11 - "atcr.io/pkg/hold/quota" 12 - ) 13 - 14 - // Manager is a no-op billing manager when billing is not compiled in. 15 - type Manager struct{} 16 - 17 - // New creates a new no-op billing manager. 18 - // This is used when the billing build tag is not set. 19 - func New(_ *quota.Manager, _ string, _ string) *Manager { 20 - return &Manager{} 21 - } 22 - 23 - // Enabled returns false when billing is not compiled in. 24 - func (m *Manager) Enabled() bool { 25 - return false 26 - } 27 - 28 - // RegisterHandlers is a no-op when billing is not compiled in. 29 - func (m *Manager) RegisterHandlers(_ chi.Router) {} 30 - 31 - // GetSubscriptionInfo returns an error when billing is not compiled in. 32 - func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) { 33 - return nil, ErrBillingDisabled 34 - } 35 - 36 - // CreateCheckoutSession returns an error when billing is not compiled in. 37 - func (m *Manager) CreateCheckoutSession(_ *http.Request, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { 38 - return nil, ErrBillingDisabled 39 - } 40 - 41 - // GetBillingPortalURL returns an error when billing is not compiled in. 42 - func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) { 43 - return nil, ErrBillingDisabled 44 - } 45 - 46 - // HandleWebhook returns an error when billing is not compiled in. 47 - func (m *Manager) HandleWebhook(_ *http.Request) (*WebhookEvent, error) { 48 - return nil, ErrBillingDisabled 49 - } 50 - 51 - // XRPCHandler is a no-op handler when billing is not compiled in. 52 - type XRPCHandler struct{} 53 - 54 - // NewXRPCHandler creates a new no-op XRPC handler. 55 - func NewXRPCHandler(_ *Manager, _ *pds.HoldPDS, _ *http.Client) *XRPCHandler { 56 - return &XRPCHandler{} 57 - } 58 - 59 - // RegisterHandlers is a no-op when billing is not compiled in. 60 - func (h *XRPCHandler) RegisterHandlers(_ chi.Router) {}
-132
pkg/hold/billing/config.go
··· 1 - //go:build billing 2 - 3 - package billing 4 - 5 - import ( 6 - "fmt" 7 - "os" 8 - 9 - "go.yaml.in/yaml/v4" 10 - ) 11 - 12 - // BillingConfig holds billing/Stripe settings parsed from the hold config YAML. 13 - // The billing section is a top-level key in the YAML file, separate from quota. 14 - type BillingConfig struct { 15 - Enabled bool 16 - Currency string 17 - SuccessURL string 18 - CancelURL string 19 - 20 - // Tier-level billing info keyed by tier name (same keys as quota tiers). 21 - Tiers map[string]BillingTierConfig 22 - 23 - // Tier assigned to plankowner crew members. 24 - PlankOwnerCrewTier string 25 - } 26 - 27 - // BillingTierConfig holds Stripe pricing for a single tier. 28 - type BillingTierConfig struct { 29 - Description string `yaml:"description,omitempty"` 30 - StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"` 31 - StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"` 32 - } 33 - 34 - // billingYAML is the top-level YAML structure for extracting the billing section. 35 - type billingYAML struct { 36 - Billing rawBillingConfig `yaml:"billing"` 37 - } 38 - 39 - type rawBillingConfig struct { 40 - Enabled bool `yaml:"enabled"` 41 - Currency string `yaml:"currency,omitempty"` 42 - SuccessURL string `yaml:"success_url,omitempty"` 43 - CancelURL string `yaml:"cancel_url,omitempty"` 44 - PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"` 45 - Tiers map[string]BillingTierConfig `yaml:"tiers,omitempty"` 46 - } 47 - 48 - // LoadBillingConfig reads the hold config YAML and extracts billing fields. 49 - // Returns (nil, nil) if the file is missing or billing is not enabled. 50 - // Returns (nil, err) if the file exists with billing enabled but is misconfigured. 51 - func LoadBillingConfig(configPath string) (*BillingConfig, error) { 52 - if configPath == "" { 53 - return nil, nil 54 - } 55 - 56 - data, err := os.ReadFile(configPath) 57 - if err != nil { 58 - if os.IsNotExist(err) { 59 - return nil, nil 60 - } 61 - return nil, fmt.Errorf("failed to read config: %w", err) 62 - } 63 - 64 - return parseBillingConfig(data) 65 - } 66 - 67 - // parseBillingConfig extracts billing fields from hold config YAML bytes. 68 - // Returns (nil, nil) if billing is not enabled. 69 - // Returns (nil, err) if billing is enabled but misconfigured. 70 - func parseBillingConfig(data []byte) (*BillingConfig, error) { 71 - var raw billingYAML 72 - if err := yaml.Unmarshal(data, &raw); err != nil { 73 - return nil, fmt.Errorf("failed to parse config: %w", err) 74 - } 75 - 76 - if !raw.Billing.Enabled { 77 - return nil, nil 78 - } 79 - 80 - cfg := &BillingConfig{ 81 - Enabled: true, 82 - Currency: raw.Billing.Currency, 83 - SuccessURL: raw.Billing.SuccessURL, 84 - CancelURL: raw.Billing.CancelURL, 85 - PlankOwnerCrewTier: raw.Billing.PlankOwnerCrewTier, 86 - Tiers: raw.Billing.Tiers, 87 - } 88 - 89 - if cfg.Tiers == nil { 90 - cfg.Tiers = make(map[string]BillingTierConfig) 91 - } 92 - 93 - // Validate: billing enabled but no tiers have any Stripe prices configured 94 - hasAnyPrice := false 95 - for _, tier := range cfg.Tiers { 96 - if tier.StripePriceMonthly != "" || tier.StripePriceYearly != "" { 97 - hasAnyPrice = true 98 - break 99 - } 100 - } 101 - if !hasAnyPrice { 102 - return nil, fmt.Errorf("billing is enabled but no tiers have Stripe prices configured") 103 - } 104 - 105 - return cfg, nil 106 - } 107 - 108 - // GetTierPricing returns billing info for a tier, or nil if not found. 109 - func (c *BillingConfig) GetTierPricing(tierKey string) *BillingTierConfig { 110 - if c == nil { 111 - return nil 112 - } 113 - t, ok := c.Tiers[tierKey] 114 - if !ok { 115 - return nil 116 - } 117 - return &t 118 - } 119 - 120 - // GetTierByPriceID finds the tier key that contains the given Stripe price ID. 121 - // Returns empty string if no match. 122 - func (c *BillingConfig) GetTierByPriceID(priceID string) string { 123 - if c == nil || priceID == "" { 124 - return "" 125 - } 126 - for key, tier := range c.Tiers { 127 - if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID { 128 - return key 129 - } 130 - } 131 - return "" 132 - }
-357
pkg/hold/billing/config_test.go
··· 1 - //go:build billing 2 - 3 - package billing 4 - 5 - import ( 6 - "os" 7 - "path/filepath" 8 - "testing" 9 - ) 10 - 11 - func TestParseBillingConfig_Disabled(t *testing.T) { 12 - yaml := []byte(` 13 - billing: 14 - enabled: false 15 - `) 16 - cfg, err := parseBillingConfig(yaml) 17 - if err != nil { 18 - t.Fatalf("unexpected error: %v", err) 19 - } 20 - if cfg != nil { 21 - t.Error("expected nil config when billing disabled") 22 - } 23 - } 24 - 25 - func TestParseBillingConfig_NoBillingSection(t *testing.T) { 26 - yaml := []byte(` 27 - quota: 28 - tiers: 29 - deckhand: 30 - quota: 5GB 31 - `) 32 - cfg, err := parseBillingConfig(yaml) 33 - if err != nil { 34 - t.Fatalf("unexpected error: %v", err) 35 - } 36 - if cfg != nil { 37 - t.Error("expected nil config when no billing section") 38 - } 39 - } 40 - 41 - func TestParseBillingConfig_Enabled(t *testing.T) { 42 - yaml := []byte(` 43 - billing: 44 - enabled: true 45 - currency: usd 46 - success_url: "{hold_url}/billing/success" 47 - cancel_url: "{hold_url}/billing/cancel" 48 - plankowner_crew_tier: bosun 49 - tiers: 50 - deckhand: 51 - description: Starter tier 52 - bosun: 53 - description: Standard tier 54 - stripe_price_monthly: price_bosun_monthly 55 - stripe_price_yearly: price_bosun_yearly 56 - `) 57 - cfg, err := parseBillingConfig(yaml) 58 - if err != nil { 59 - t.Fatalf("unexpected error: %v", err) 60 - } 61 - if cfg == nil { 62 - t.Fatal("expected non-nil config") 63 - } 64 - 65 - if !cfg.Enabled { 66 - t.Error("expected Enabled=true") 67 - } 68 - if cfg.Currency != "usd" { 69 - t.Errorf("expected currency 'usd', got %q", cfg.Currency) 70 - } 71 - if cfg.PlankOwnerCrewTier != "bosun" { 72 - t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier) 73 - } 74 - if cfg.SuccessURL != "{hold_url}/billing/success" { 75 - t.Errorf("unexpected success_url: %q", cfg.SuccessURL) 76 - } 77 - 78 - // Check tier pricing 79 - bosun := cfg.GetTierPricing("bosun") 80 - if bosun == nil { 81 - t.Fatal("expected bosun tier pricing") 82 - } 83 - if bosun.StripePriceMonthly != "price_bosun_monthly" { 84 - t.Errorf("expected bosun monthly price 'price_bosun_monthly', got %q", bosun.StripePriceMonthly) 85 - } 86 - if bosun.StripePriceYearly != "price_bosun_yearly" { 87 - t.Errorf("expected bosun yearly price 'price_bosun_yearly', got %q", bosun.StripePriceYearly) 88 - } 89 - if bosun.Description != "Standard tier" { 90 - t.Errorf("expected bosun description 'Standard tier', got %q", bosun.Description) 91 - } 92 - 93 - // Deckhand has no prices 94 - deckhand := cfg.GetTierPricing("deckhand") 95 - if deckhand == nil { 96 - t.Fatal("expected deckhand tier pricing entry") 97 - } 98 - if deckhand.StripePriceMonthly != "" { 99 - t.Error("expected no monthly price for deckhand") 100 - } 101 - } 102 - 103 - func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) { 104 - yaml := []byte(` 105 - billing: 106 - enabled: true 107 - currency: usd 108 - `) 109 - cfg, err := parseBillingConfig(yaml) 110 - if err == nil { 111 - t.Error("expected error when billing enabled but no prices configured") 112 - } 113 - if cfg != nil { 114 - t.Error("expected nil config on error") 115 - } 116 - } 117 - 118 - func TestGetTierByPriceID(t *testing.T) { 119 - cfg := &BillingConfig{ 120 - Tiers: map[string]BillingTierConfig{ 121 - "deckhand": {}, 122 - "bosun": {StripePriceMonthly: "price_m", StripePriceYearly: "price_y"}, 123 - }, 124 - } 125 - 126 - if got := cfg.GetTierByPriceID("price_m"); got != "bosun" { 127 - t.Errorf("expected 'bosun' for monthly price, got %q", got) 128 - } 129 - if got := cfg.GetTierByPriceID("price_y"); got != "bosun" { 130 - t.Errorf("expected 'bosun' for yearly price, got %q", got) 131 - } 132 - if got := cfg.GetTierByPriceID("price_unknown"); got != "" { 133 - t.Errorf("expected empty for unknown price, got %q", got) 134 - } 135 - if got := cfg.GetTierByPriceID(""); got != "" { 136 - t.Errorf("expected empty for empty price, got %q", got) 137 - } 138 - 139 - // nil receiver 140 - var nilCfg *BillingConfig 141 - if got := nilCfg.GetTierByPriceID("price_m"); got != "" { 142 - t.Errorf("expected empty from nil config, got %q", got) 143 - } 144 - } 145 - 146 - func TestGetTierPricing_NilConfig(t *testing.T) { 147 - var cfg *BillingConfig 148 - if cfg.GetTierPricing("anything") != nil { 149 - t.Error("expected nil from nil config") 150 - } 151 - } 152 - 153 - func TestLoadBillingConfig_MissingFile(t *testing.T) { 154 - cfg, err := LoadBillingConfig("/nonexistent/config.yaml") 155 - if err != nil { 156 - t.Fatalf("expected no error for missing file, got: %v", err) 157 - } 158 - if cfg != nil { 159 - t.Error("expected nil config for missing file") 160 - } 161 - } 162 - 163 - func TestLoadBillingConfig_EmptyPath(t *testing.T) { 164 - cfg, err := LoadBillingConfig("") 165 - if err != nil { 166 - t.Fatalf("unexpected error: %v", err) 167 - } 168 - if cfg != nil { 169 - t.Error("expected nil config for empty path") 170 - } 171 - } 172 - 173 - func TestLoadBillingConfig_FromFile(t *testing.T) { 174 - dir := t.TempDir() 175 - path := filepath.Join(dir, "config.yaml") 176 - 177 - content := ` 178 - billing: 179 - enabled: true 180 - currency: usd 181 - tiers: 182 - bosun: 183 - stripe_price_monthly: price_test 184 - ` 185 - if err := os.WriteFile(path, []byte(content), 0644); err != nil { 186 - t.Fatal(err) 187 - } 188 - 189 - cfg, err := LoadBillingConfig(path) 190 - if err != nil { 191 - t.Fatalf("unexpected error: %v", err) 192 - } 193 - if cfg == nil { 194 - t.Fatal("expected non-nil config") 195 - } 196 - if cfg.GetTierByPriceID("price_test") != "bosun" { 197 - t.Error("expected bosun tier for price_test") 198 - } 199 - } 200 - 201 - func TestParseBillingConfig_TopLevelTiers(t *testing.T) { 202 - yaml := []byte(` 203 - billing: 204 - enabled: true 205 - currency: usd 206 - tiers: 207 - deckhand: 208 - description: "Starter tier" 209 - bosun: 210 - description: "Standard tier" 211 - stripe_price_monthly: price_bosun_m 212 - stripe_price_yearly: price_bosun_y 213 - quartermaster: 214 - description: "Pro tier" 215 - stripe_price_monthly: price_qm_m 216 - `) 217 - cfg, err := parseBillingConfig(yaml) 218 - if err != nil { 219 - t.Fatalf("unexpected error: %v", err) 220 - } 221 - if cfg == nil { 222 - t.Fatal("expected non-nil config") 223 - } 224 - if len(cfg.Tiers) != 3 { 225 - t.Errorf("expected 3 tiers, got %d", len(cfg.Tiers)) 226 - } 227 - 228 - bosun := cfg.GetTierPricing("bosun") 229 - if bosun == nil { 230 - t.Fatal("expected bosun tier") 231 - } 232 - if bosun.Description != "Standard tier" { 233 - t.Errorf("expected description 'Standard tier', got %q", bosun.Description) 234 - } 235 - if bosun.StripePriceMonthly != "price_bosun_m" { 236 - t.Errorf("expected monthly price 'price_bosun_m', got %q", bosun.StripePriceMonthly) 237 - } 238 - if bosun.StripePriceYearly != "price_bosun_y" { 239 - t.Errorf("expected yearly price 'price_bosun_y', got %q", bosun.StripePriceYearly) 240 - } 241 - 242 - qm := cfg.GetTierPricing("quartermaster") 243 - if qm == nil { 244 - t.Fatal("expected quartermaster tier") 245 - } 246 - if qm.StripePriceMonthly != "price_qm_m" { 247 - t.Errorf("expected monthly price 'price_qm_m', got %q", qm.StripePriceMonthly) 248 - } 249 - 250 - deckhand := cfg.GetTierPricing("deckhand") 251 - if deckhand == nil { 252 - t.Fatal("expected deckhand tier") 253 - } 254 - if deckhand.Description != "Starter tier" { 255 - t.Errorf("expected description 'Starter tier', got %q", deckhand.Description) 256 - } 257 - if deckhand.StripePriceMonthly != "" { 258 - t.Error("expected no monthly price for deckhand") 259 - } 260 - } 261 - 262 - func TestParseBillingConfig_PlankOwnerCrewTier(t *testing.T) { 263 - yaml := []byte(` 264 - billing: 265 - enabled: true 266 - currency: usd 267 - plankowner_crew_tier: bosun 268 - tiers: 269 - bosun: 270 - stripe_price_monthly: price_bosun_m 271 - `) 272 - cfg, err := parseBillingConfig(yaml) 273 - if err != nil { 274 - t.Fatalf("unexpected error: %v", err) 275 - } 276 - if cfg == nil { 277 - t.Fatal("expected non-nil config") 278 - } 279 - if cfg.PlankOwnerCrewTier != "bosun" { 280 - t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier) 281 - } 282 - } 283 - 284 - func TestParseBillingConfig_IgnoresQuotaSection(t *testing.T) { 285 - // Billing parser should work even if quota section is missing entirely 286 - yaml := []byte(` 287 - billing: 288 - enabled: true 289 - currency: usd 290 - tiers: 291 - bosun: 292 - stripe_price_monthly: price_bosun_m 293 - `) 294 - cfg, err := parseBillingConfig(yaml) 295 - if err != nil { 296 - t.Fatalf("unexpected error: %v", err) 297 - } 298 - if cfg == nil { 299 - t.Fatal("expected non-nil config") 300 - } 301 - 302 - // Also works with quota present but unrelated 303 - yaml2 := []byte(` 304 - quota: 305 - tiers: 306 - swabbie: 307 - quota: 1GB 308 - billing: 309 - enabled: true 310 - currency: usd 311 - tiers: 312 - bosun: 313 - stripe_price_monthly: price_bosun_m 314 - `) 315 - cfg2, err := parseBillingConfig(yaml2) 316 - if err != nil { 317 - t.Fatalf("unexpected error: %v", err) 318 - } 319 - if cfg2 == nil { 320 - t.Fatal("expected non-nil config") 321 - } 322 - // Billing should only see its own tiers, not quota tiers 323 - if cfg2.GetTierPricing("swabbie") != nil { 324 - t.Error("billing should not contain quota-only tiers") 325 - } 326 - } 327 - 328 - func TestParseBillingConfig_EmptyTiers(t *testing.T) { 329 - // Billing enabled with explicit empty tiers 330 - yaml := []byte(` 331 - billing: 332 - enabled: true 333 - currency: usd 334 - tiers: {} 335 - `) 336 - cfg, err := parseBillingConfig(yaml) 337 - if err == nil { 338 - t.Error("expected error when billing enabled with empty tiers") 339 - } 340 - if cfg != nil { 341 - t.Error("expected nil config on error") 342 - } 343 - 344 - // Billing enabled with tiers omitted entirely 345 - yaml2 := []byte(` 346 - billing: 347 - enabled: true 348 - currency: usd 349 - `) 350 - cfg2, err := parseBillingConfig(yaml2) 351 - if err == nil { 352 - t.Error("expected error when billing enabled with no tiers") 353 - } 354 - if cfg2 != nil { 355 - t.Error("expected nil config on error") 356 - } 357 - }
-222
pkg/hold/billing/handlers.go
··· 1 - //go:build billing 2 - 3 - package billing 4 - 5 - import ( 6 - "encoding/json" 7 - "log/slog" 8 - "net/http" 9 - 10 - "github.com/go-chi/chi/v5" 11 - 12 - "atcr.io/pkg/hold/pds" 13 - ) 14 - 15 - // XRPCHandler handles billing-related XRPC endpoints. 16 - type XRPCHandler struct { 17 - manager *Manager 18 - pdsServer *pds.HoldPDS 19 - httpClient *http.Client 20 - } 21 - 22 - // NewXRPCHandler creates a new billing XRPC handler. 23 - func NewXRPCHandler(manager *Manager, pdsServer *pds.HoldPDS, httpClient *http.Client) *XRPCHandler { 24 - return &XRPCHandler{ 25 - manager: manager, 26 - pdsServer: pdsServer, 27 - httpClient: httpClient, 28 - } 29 - } 30 - 31 - // RegisterHandlers registers billing XRPC endpoints on the router. 32 - func (m *Manager) RegisterHandlers(r chi.Router) { 33 - // This is a no-op for the Manager itself 34 - // Use NewXRPCHandler and call its RegisterHandlers method 35 - } 36 - 37 - // RegisterHandlers registers billing endpoints on the router. 38 - func (h *XRPCHandler) RegisterHandlers(r chi.Router) { 39 - if !h.manager.Enabled() { 40 - slog.Info("Billing endpoints disabled (not configured)") 41 - return 42 - } 43 - 44 - slog.Info("Registering billing XRPC endpoints") 45 - 46 - // Public endpoint - get subscription info (auth optional for tiers list) 47 - r.Get("/xrpc/io.atcr.hold.getSubscriptionInfo", h.HandleGetSubscriptionInfo) 48 - 49 - // Authenticated endpoints 50 - r.Group(func(r chi.Router) { 51 - r.Use(h.requireAuth) 52 - r.Post("/xrpc/io.atcr.hold.createCheckoutSession", h.HandleCreateCheckoutSession) 53 - r.Get("/xrpc/io.atcr.hold.getBillingPortalUrl", h.HandleGetBillingPortalURL) 54 - }) 55 - 56 - // Stripe webhook (authenticated by Stripe signature) 57 - r.Post("/xrpc/io.atcr.hold.stripeWebhook", h.HandleStripeWebhook) 58 - } 59 - 60 - // requireAuth is middleware that validates user authentication. 61 - func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler { 62 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 - // Use the same auth validation as other hold endpoints 64 - user, err := pds.ValidateDPoPRequest(r, h.httpClient) 65 - if err != nil { 66 - // Try service token 67 - user, err = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient) 68 - } 69 - if err != nil { 70 - respondError(w, http.StatusUnauthorized, "authentication required") 71 - return 72 - } 73 - 74 - // Store user DID in header for handlers 75 - r.Header.Set("X-User-DID", user.DID) 76 - next.ServeHTTP(w, r) 77 - }) 78 - } 79 - 80 - // HandleGetSubscriptionInfo returns subscription and quota information. 81 - // GET /xrpc/io.atcr.hold.getSubscriptionInfo?userDid=did:plc:xxx 82 - func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.Request) { 83 - userDID := r.URL.Query().Get("userDid") 84 - 85 - // If no userDID provided, try to get from auth 86 - if userDID == "" { 87 - // Try to authenticate (optional) 88 - user, err := pds.ValidateDPoPRequest(r, h.httpClient) 89 - if err != nil { 90 - user, _ = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient) 91 - } 92 - if user != nil { 93 - userDID = user.DID 94 - } 95 - } 96 - 97 - info, err := h.manager.GetSubscriptionInfo(userDID) 98 - if err != nil { 99 - if err == ErrBillingDisabled { 100 - // Return basic info with payments disabled 101 - respondJSON(w, http.StatusOK, &SubscriptionInfo{ 102 - UserDID: userDID, 103 - PaymentsEnabled: false, 104 - Tiers: h.manager.buildTierList(userDID), 105 - }) 106 - return 107 - } 108 - respondError(w, http.StatusInternalServerError, err.Error()) 109 - return 110 - } 111 - 112 - // Get current usage and crew tier from PDS quota stats 113 - if userDID != "" { 114 - stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr) 115 - if err == nil { 116 - info.CurrentUsage = stats.TotalSize 117 - info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced) 118 - info.CurrentLimit = stats.Limit 119 - 120 - // If no subscription but crew has a tier, show that as current 121 - if info.SubscriptionID == "" && info.CrewTier != "" { 122 - info.CurrentTier = info.CrewTier 123 - } 124 - } 125 - } 126 - 127 - // Mark which tier is actually current (use crew tier if available, otherwise subscription tier) 128 - effectiveTier := info.CurrentTier 129 - if info.CrewTier != "" { 130 - effectiveTier = info.CrewTier 131 - } 132 - for i := range info.Tiers { 133 - info.Tiers[i].IsCurrent = info.Tiers[i].ID == effectiveTier 134 - } 135 - 136 - respondJSON(w, http.StatusOK, info) 137 - } 138 - 139 - // HandleCreateCheckoutSession creates a Stripe checkout session. 140 - // POST /xrpc/io.atcr.hold.createCheckoutSession 141 - func (h *XRPCHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) { 142 - var req CheckoutSessionRequest 143 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 144 - respondError(w, http.StatusBadRequest, "invalid request body") 145 - return 146 - } 147 - 148 - if req.Tier == "" { 149 - respondError(w, http.StatusBadRequest, "tier is required") 150 - return 151 - } 152 - 153 - resp, err := h.manager.CreateCheckoutSession(r, &req) 154 - if err != nil { 155 - slog.Error("Failed to create checkout session", "error", err) 156 - respondError(w, http.StatusInternalServerError, err.Error()) 157 - return 158 - } 159 - 160 - respondJSON(w, http.StatusOK, resp) 161 - } 162 - 163 - // HandleGetBillingPortalURL returns a URL to the Stripe billing portal. 164 - // GET /xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=https://... 165 - func (h *XRPCHandler) HandleGetBillingPortalURL(w http.ResponseWriter, r *http.Request) { 166 - userDID := r.Header.Get("X-User-DID") 167 - returnURL := r.URL.Query().Get("returnUrl") 168 - 169 - resp, err := h.manager.GetBillingPortalURL(userDID, returnURL) 170 - if err != nil { 171 - slog.Error("Failed to get billing portal URL", "error", err, "userDid", userDID) 172 - respondError(w, http.StatusInternalServerError, err.Error()) 173 - return 174 - } 175 - 176 - respondJSON(w, http.StatusOK, resp) 177 - } 178 - 179 - // HandleStripeWebhook processes Stripe webhook events. 180 - // POST /xrpc/io.atcr.hold.stripeWebhook 181 - func (h *XRPCHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) { 182 - event, err := h.manager.HandleWebhook(r) 183 - if err != nil { 184 - slog.Error("Failed to process webhook", "error", err) 185 - respondError(w, http.StatusBadRequest, err.Error()) 186 - return 187 - } 188 - 189 - // If we have a tier update, apply it to the crew record 190 - if event.UserDID != "" && event.NewTier != "" { 191 - if err := h.pdsServer.UpdateCrewMemberTier(r.Context(), event.UserDID, event.NewTier); err != nil { 192 - slog.Error("Failed to update crew tier", "error", err, "userDid", event.UserDID, "tier", event.NewTier) 193 - // Don't fail the webhook - Stripe will retry 194 - } else { 195 - slog.Info("Updated crew tier from subscription", 196 - "userDid", event.UserDID, 197 - "tier", event.NewTier, 198 - "event", event.Type, 199 - ) 200 - } 201 - 202 - // Invalidate customer cache since subscription changed 203 - h.manager.InvalidateCustomerCache(event.UserDID) 204 - } 205 - 206 - // Return 200 to acknowledge receipt 207 - respondJSON(w, http.StatusOK, map[string]string{"received": "true"}) 208 - } 209 - 210 - // respondJSON writes a JSON response. 211 - func respondJSON(w http.ResponseWriter, status int, v any) { 212 - w.Header().Set("Content-Type", "application/json") 213 - w.WriteHeader(status) 214 - if err := json.NewEncoder(w).Encode(v); err != nil { 215 - slog.Error("Failed to encode JSON response", "error", err) 216 - } 217 - } 218 - 219 - // respondError writes a JSON error response. 220 - func respondError(w http.ResponseWriter, status int, message string) { 221 - respondJSON(w, status, map[string]string{"error": message}) 222 - }
-65
pkg/hold/billing/types.go
··· 1 - // Package billing provides optional Stripe billing integration for hold services. 2 - // This package uses build tags to conditionally compile Stripe support. 3 - // Build with -tags billing to enable Stripe integration. 4 - package billing 5 - 6 - import "errors" 7 - 8 - // ErrBillingDisabled is returned when billing operations are attempted 9 - // but billing is not enabled (either not compiled in or disabled at runtime). 10 - var ErrBillingDisabled = errors.New("billing not enabled") 11 - 12 - // SubscriptionInfo contains subscription and quota information for a user. 13 - type SubscriptionInfo struct { 14 - UserDID string `json:"userDid"` 15 - CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default) 16 - CrewTier string `json:"crewTier,omitempty"` // tier from local crew record (what's actually enforced) 17 - CurrentUsage int64 `json:"currentUsage"` // bytes used 18 - CurrentLimit *int64 `json:"currentLimit,omitempty"` // nil = unlimited 19 - PaymentsEnabled bool `json:"paymentsEnabled"` // whether online payments are available 20 - Tiers []TierInfo `json:"tiers"` // available tiers 21 - SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active 22 - CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists 23 - BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly" 24 - } 25 - 26 - // TierInfo describes a single tier available for subscription. 27 - type TierInfo struct { 28 - ID string `json:"id"` // tier key (e.g., "deckhand", "bosun") 29 - Name string `json:"name"` // display name (same as ID if not specified) 30 - Description string `json:"description,omitempty"` // human-readable description 31 - QuotaBytes int64 `json:"quotaBytes"` // quota limit in bytes 32 - QuotaFormatted string `json:"quotaFormatted"` // human-readable quota (e.g., "5 GB") 33 - PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free) 34 - PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available) 35 - IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier 36 - } 37 - 38 - // CheckoutSessionRequest is the request to create a Stripe checkout session. 39 - type CheckoutSessionRequest struct { 40 - Tier string `json:"tier"` // tier to subscribe to 41 - Interval string `json:"interval,omitempty"` // "monthly" or "yearly" (default: monthly) 42 - ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout 43 - } 44 - 45 - // CheckoutSessionResponse is the response with the Stripe checkout URL. 46 - type CheckoutSessionResponse struct { 47 - CheckoutURL string `json:"checkoutUrl"` 48 - SessionID string `json:"sessionId"` 49 - } 50 - 51 - // BillingPortalResponse is the response with the Stripe billing portal URL. 52 - type BillingPortalResponse struct { 53 - PortalURL string `json:"portalUrl"` 54 - } 55 - 56 - // WebhookEvent represents a processed Stripe webhook event. 57 - type WebhookEvent struct { 58 - Type string `json:"type"` // e.g., "customer.subscription.updated" 59 - CustomerID string `json:"customerId"` // Stripe customer ID 60 - UserDID string `json:"userDid"` // user's DID from customer metadata 61 - SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID 62 - PriceID string `json:"priceId,omitempty"` // Stripe price ID 63 - NewTier string `json:"newTier,omitempty"` // resolved tier name 64 - Status string `json:"status,omitempty"` // subscription status 65 - }
+29 -7
pkg/hold/config.go
··· 10 10 "fmt" 11 11 "log/slog" 12 12 "path/filepath" 13 + "strings" 13 14 "time" 14 15 15 16 "github.com/spf13/viper" ··· 18 19 "atcr.io/pkg/hold/gc" 19 20 "atcr.io/pkg/hold/quota" 20 21 ) 22 + 23 + // URLFromDIDWeb converts a did:web identifier to an HTTPS URL. 24 + // This is the inverse of the did:web spec encoding: 25 + // 26 + // "did:web:atcr.io" → "https://atcr.io" 27 + // "did:web:localhost%3A8080" → "https://localhost:8080" 28 + // 29 + // Returns empty string for non-did:web identifiers. 30 + func URLFromDIDWeb(did string) string { 31 + if !strings.HasPrefix(did, "did:web:") { 32 + return "" 33 + } 34 + host := strings.TrimPrefix(did, "did:web:") 35 + // Per did:web spec, %3A encodes port colon 36 + host = strings.ReplaceAll(host, "%3A", ":") 37 + return "https://" + host 38 + } 21 39 22 40 // Config represents the hold service configuration 23 41 type Config struct { ··· 128 146 // Request crawl from this relay on startup. 129 147 RelayEndpoint string `yaml:"relay_endpoint" comment:"Request crawl from this relay on startup to make the embedded PDS discoverable."` 130 148 131 - // Preferred appview URL for links in webhooks and Bluesky posts. 132 - AppviewURL string `yaml:"appview_url" comment:"Preferred appview URL for links in webhooks and Bluesky posts, e.g. \"https://seamark.dev\"."` 149 + // DID of the appview this hold is managed by. Resolved via did:web for URL and public key discovery. 150 + AppviewDID string `yaml:"appview_did" comment:"DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key."` 133 151 134 152 // ReadTimeout for HTTP requests. 135 153 ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."` 136 154 137 155 // WriteTimeout for HTTP requests. 138 156 WriteTimeout time.Duration `yaml:"write_timeout" comment:"Write timeout for HTTP requests."` 157 + } 158 + 159 + // AppviewURL derives the appview base URL from AppviewDID. 160 + func (s ServerConfig) AppviewURL() string { 161 + return URLFromDIDWeb(s.AppviewDID) 139 162 } 140 163 141 164 // ScannerConfig defines vulnerability scanner settings ··· 189 212 v.SetDefault("server.successor", "") 190 213 v.SetDefault("server.test_mode", false) 191 214 v.SetDefault("server.relay_endpoint", "") 192 - v.SetDefault("server.appview_url", "https://atcr.io") 215 + v.SetDefault("server.appview_did", "did:web:atcr.io") 193 216 v.SetDefault("server.read_timeout", "5m") 194 217 v.SetDefault("server.write_timeout", "5m") 195 218 ··· 252 275 // Populate example quota tiers so operators see the structure 253 276 cfg.Quota = quota.Config{ 254 277 Tiers: []quota.TierConfig{ 255 - {Name: "deckhand", Quota: "5GB", MaxWebhooks: 1}, 256 - {Name: "bosun", Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true}, 257 - {Name: "quartermaster", Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true}, 278 + {Name: "deckhand", Quota: "5GB"}, 279 + {Name: "bosun", Quota: "50GB", ScanOnPush: true}, 280 + {Name: "quartermaster", Quota: "100GB", ScanOnPush: true}, 258 281 }, 259 282 Defaults: quota.DefaultsConfig{ 260 283 NewCrewTier: "deckhand", 261 - OwnerBadge: true, 262 284 }, 263 285 } 264 286
+152
pkg/hold/pds/auth.go
··· 529 529 }, nil 530 530 } 531 531 532 + // ValidateAppviewToken validates a JWT signed by the trusted appview using ES256 (P-256). 533 + // It resolves the appview's DID document to extract the P-256 public key, then verifies 534 + // the JWT signature, issuer (iss), and audience (aud). 535 + // 536 + // Returns the subject (sub) claim which is the user DID being acted upon. 537 + func ValidateAppviewToken(r *http.Request, appviewDID, holdDID string) (string, error) { 538 + // Extract Authorization header 539 + authHeader := r.Header.Get("Authorization") 540 + if authHeader == "" { 541 + return "", ErrMissingAuthHeader 542 + } 543 + 544 + parts := strings.SplitN(authHeader, " ", 2) 545 + if len(parts) != 2 || parts[0] != "Bearer" { 546 + return "", fmt.Errorf("expected Bearer authorization scheme") 547 + } 548 + 549 + tokenString := parts[1] 550 + if tokenString == "" { 551 + return "", ErrMissingToken 552 + } 553 + 554 + // Manually parse JWT 555 + tokenParts := strings.Split(tokenString, ".") 556 + if len(tokenParts) != 3 { 557 + return "", ErrInvalidJWTFormat 558 + } 559 + 560 + // Decode and parse claims 561 + payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1]) 562 + if err != nil { 563 + return "", fmt.Errorf("failed to decode JWT payload: %w", err) 564 + } 565 + 566 + var claims ServiceTokenClaims 567 + if err := json.Unmarshal(payloadBytes, &claims); err != nil { 568 + return "", fmt.Errorf("failed to unmarshal claims: %w", err) 569 + } 570 + 571 + // Verify issuer matches configured appview DID 572 + if claims.Issuer != appviewDID { 573 + return "", fmt.Errorf("token issuer mismatch: expected %s, got %s", appviewDID, claims.Issuer) 574 + } 575 + 576 + // Verify audience matches this hold's DID 577 + audiences, err := claims.GetAudience() 578 + if err != nil { 579 + return "", fmt.Errorf("failed to get audience: %w", err) 580 + } 581 + if len(audiences) == 0 || audiences[0] != holdDID { 582 + return "", fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences) 583 + } 584 + 585 + // Verify expiration 586 + exp, err := claims.GetExpirationTime() 587 + if err != nil { 588 + return "", fmt.Errorf("failed to get expiration: %w", err) 589 + } 590 + if exp != nil && time.Now().After(exp.Time) { 591 + return "", ErrTokenExpired 592 + } 593 + 594 + // Get subject (user DID) 595 + subject, err := claims.GetSubject() 596 + if err != nil || subject == "" { 597 + return "", ErrMissingSubClaim 598 + } 599 + 600 + // Fetch P-256 public key from appview DID document 601 + pubKey, err := fetchP256PublicKeyFromDID(r.Context(), appviewDID) 602 + if err != nil { 603 + return "", fmt.Errorf("failed to fetch appview public key: %w", err) 604 + } 605 + 606 + // Verify JWT signature with P-256 key 607 + signedData := []byte(tokenParts[0] + "." + tokenParts[1]) 608 + signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2]) 609 + if err != nil { 610 + return "", fmt.Errorf("failed to decode signature: %w", err) 611 + } 612 + 613 + if err := pubKey.HashAndVerifyLenient(signedData, signature); err != nil { 614 + return "", fmt.Errorf("signature verification failed: %w", err) 615 + } 616 + 617 + slog.Debug("Validated appview service token", "appviewDID", appviewDID, "userDID", subject) 618 + return subject, nil 619 + } 620 + 621 + // fetchP256PublicKeyFromDID fetches a P-256 public key from a did:web DID document. 622 + // It resolves the DID document and looks for a Multikey verification method with P-256 prefix. 623 + func fetchP256PublicKeyFromDID(ctx context.Context, did string) (*atcrypto.PublicKeyP256, error) { 624 + if !strings.HasPrefix(did, "did:web:") { 625 + return nil, fmt.Errorf("only did:web is supported for appview DID, got %s", did) 626 + } 627 + 628 + // Resolve did:web to URL 629 + host := strings.TrimPrefix(did, "did:web:") 630 + host = strings.ReplaceAll(host, "%3A", ":") 631 + scheme := "https" 632 + if atproto.IsTestMode() { 633 + scheme = "http" 634 + } 635 + didDocURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, host) 636 + 637 + req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil) 638 + if err != nil { 639 + return nil, fmt.Errorf("failed to create request: %w", err) 640 + } 641 + 642 + resp, err := http.DefaultClient.Do(req) 643 + if err != nil { 644 + return nil, fmt.Errorf("failed to fetch DID document: %w", err) 645 + } 646 + defer resp.Body.Close() 647 + 648 + if resp.StatusCode != http.StatusOK { 649 + return nil, fmt.Errorf("DID document fetch returned status %d", resp.StatusCode) 650 + } 651 + 652 + var doc struct { 653 + VerificationMethod []struct { 654 + ID string `json:"id"` 655 + Type string `json:"type"` 656 + PublicKeyMultibase string `json:"publicKeyMultibase"` 657 + } `json:"verificationMethod"` 658 + } 659 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 660 + return nil, fmt.Errorf("failed to decode DID document: %w", err) 661 + } 662 + 663 + // Find a Multikey verification method with P-256 public key 664 + for _, vm := range doc.VerificationMethod { 665 + if vm.Type != "Multikey" || vm.PublicKeyMultibase == "" { 666 + continue 667 + } 668 + 669 + // Try parsing as P-256 key via atcrypto's multibase parser 670 + pubKey, err := atcrypto.ParsePublicMultibase(vm.PublicKeyMultibase) 671 + if err != nil { 672 + continue 673 + } 674 + 675 + p256Key, ok := pubKey.(*atcrypto.PublicKeyP256) 676 + if ok { 677 + return p256Key, nil 678 + } 679 + } 680 + 681 + return nil, fmt.Errorf("no P-256 public key found in DID document for %s", did) 682 + } 683 + 532 684 // fetchPublicKeyFromDID fetches the public key from a DID document 533 685 // Supports did:plc and did:web 534 686 // Returns the atcrypto.PublicKey for signature verification
-18
pkg/hold/pds/scan_broadcaster.go
··· 145 145 db.Close() 146 146 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err) 147 147 } 148 - if err := sb.initWebhookSchema(); err != nil { 149 - db.Close() 150 - return nil, fmt.Errorf("failed to initialize webhook schema: %w", err) 151 - } 152 - 153 148 // Start re-dispatch loop for timed-out jobs 154 149 sb.wg.Add(1) 155 150 go sb.reDispatchLoop() ··· 191 186 if err := sb.initSchema(); err != nil { 192 187 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err) 193 188 } 194 - if err := sb.initWebhookSchema(); err != nil { 195 - return nil, fmt.Errorf("failed to initialize webhook schema: %w", err) 196 - } 197 - 198 189 sb.wg.Add(1) 199 190 go sb.reDispatchLoop() 200 191 ··· 517 508 518 509 // Store scan result as a record in the hold's embedded PDS 519 510 if msg.Summary != nil { 520 - // Check for existing scan record before creating new one (for webhook dispatch) 521 - var previousScan *atproto.ScanRecord 522 - _, prevScan, err := sb.pds.GetScanRecord(ctx, manifestDigest) 523 - if err == nil { 524 - previousScan = prevScan 525 - } 526 - 527 511 scanRecord := atproto.NewScanRecord( 528 512 manifestDigest, repository, userDID, 529 513 sbomBlob, vulnReportBlob, ··· 545 529 "total", msg.Summary.Total) 546 530 } 547 531 548 - // Dispatch webhooks after scan record is stored 549 - go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle, msg.Summary, previousScan) 550 532 } 551 533 552 534 // Mark job as completed
+1 -2
pkg/hold/pds/server.go
··· 32 32 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 33 33 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{}) 34 34 lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{}) 35 - lexutil.RegisterType(atproto.WebhookCollection, &atproto.HoldWebhookRecord{}) 36 35 } 37 36 38 37 // HoldPDS is a minimal ATProto PDS implementation for a hold service ··· 50 49 recordsIndex *RecordsIndex 51 50 } 52 51 53 - // AppviewURL returns the configured appview base URL for links in webhooks and posts. 52 + // AppviewURL returns the configured appview base URL for links in Bluesky posts. 54 53 func (p *HoldPDS) AppviewURL() string { return p.appviewURL } 55 54 56 55 // AppviewMeta returns cached appview metadata, or defaults derived from the appview URL.
-831
pkg/hold/pds/webhooks.go
··· 1 - package pds 2 - 3 - import ( 4 - "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "log/slog" 12 - "math/rand/v2" 13 - "net/http" 14 - "net/url" 15 - "strings" 16 - "time" 17 - 18 - "atcr.io/pkg/atproto" 19 - "github.com/go-chi/chi/v5" 20 - "github.com/go-chi/render" 21 - "github.com/ipfs/go-cid" 22 - ) 23 - 24 - // webhookConfig represents a webhook for list/display (masked URL, no secret) 25 - type webhookConfig struct { 26 - Rkey string `json:"rkey"` 27 - Triggers int `json:"triggers"` 28 - URL string `json:"url"` // masked 29 - HasSecret bool `json:"hasSecret"` 30 - CreatedAt string `json:"createdAt"` 31 - } 32 - 33 - // activeWebhook is the internal representation with secret for dispatch 34 - type activeWebhook struct { 35 - Rkey string 36 - URL string 37 - Secret string 38 - Triggers int 39 - } 40 - 41 - // WebhookPayload is the JSON body sent to webhook URLs 42 - type WebhookPayload struct { 43 - Trigger string `json:"trigger"` 44 - HoldDID string `json:"holdDid"` 45 - HoldEndpoint string `json:"holdEndpoint"` 46 - Manifest WebhookManifestInfo `json:"manifest"` 47 - Scan WebhookScanInfo `json:"scan"` 48 - Previous *WebhookVulnCounts `json:"previous"` 49 - } 50 - 51 - // WebhookManifestInfo describes the scanned manifest 52 - type WebhookManifestInfo struct { 53 - Digest string `json:"digest"` 54 - Repository string `json:"repository"` 55 - Tag string `json:"tag"` 56 - UserDID string `json:"userDid"` 57 - UserHandle string `json:"userHandle,omitempty"` 58 - } 59 - 60 - // WebhookScanInfo describes the scan results 61 - type WebhookScanInfo struct { 62 - ScannedAt string `json:"scannedAt"` 63 - ScannerVersion string `json:"scannerVersion"` 64 - Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"` 65 - } 66 - 67 - // WebhookVulnCounts contains vulnerability counts by severity 68 - type WebhookVulnCounts struct { 69 - Critical int `json:"critical"` 70 - High int `json:"high"` 71 - Medium int `json:"medium"` 72 - Low int `json:"low"` 73 - Total int `json:"total"` 74 - } 75 - 76 - // initWebhookSchema creates the webhook_secrets table. 77 - // Called from ScanBroadcaster init alongside scan_jobs table. 78 - func (sb *ScanBroadcaster) initWebhookSchema() error { 79 - stmts := []string{ 80 - `CREATE TABLE IF NOT EXISTS webhook_secrets ( 81 - rkey TEXT PRIMARY KEY, 82 - user_did TEXT NOT NULL, 83 - url TEXT NOT NULL, 84 - secret TEXT 85 - )`, 86 - `CREATE INDEX IF NOT EXISTS idx_webhook_secrets_user ON webhook_secrets(user_did)`, 87 - } 88 - for _, stmt := range stmts { 89 - if _, err := sb.db.Exec(stmt); err != nil { 90 - return fmt.Errorf("failed to create webhook_secrets table: %w", err) 91 - } 92 - } 93 - return nil 94 - } 95 - 96 - // CountWebhooks returns the number of webhooks configured for a user 97 - func (sb *ScanBroadcaster) CountWebhooks(userDID string) (int, error) { 98 - var count int 99 - err := sb.db.QueryRow(`SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?`, userDID).Scan(&count) 100 - return count, err 101 - } 102 - 103 - // ListWebhookConfigs returns webhook configurations for display (masked URLs) 104 - func (sb *ScanBroadcaster) ListWebhookConfigs(userDID string) ([]webhookConfig, error) { 105 - rows, err := sb.db.Query(` 106 - SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ? 107 - `, userDID) 108 - if err != nil { 109 - return nil, err 110 - } 111 - defer rows.Close() 112 - 113 - var configs []webhookConfig 114 - for rows.Next() { 115 - var rkey, rawURL, secret string 116 - if err := rows.Scan(&rkey, &rawURL, &secret); err != nil { 117 - continue 118 - } 119 - 120 - // Get triggers from PDS record 121 - triggers := 0 122 - _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, rkey, cid.Undef) 123 - if err == nil { 124 - if rec, ok := val.(*atproto.HoldWebhookRecord); ok { 125 - triggers = int(rec.Triggers) 126 - } 127 - } 128 - 129 - // Get createdAt from PDS record 130 - createdAt := "" 131 - if val != nil { 132 - if rec, ok := val.(*atproto.HoldWebhookRecord); ok { 133 - createdAt = rec.CreatedAt 134 - } 135 - } 136 - 137 - configs = append(configs, webhookConfig{ 138 - Rkey: rkey, 139 - Triggers: triggers, 140 - URL: maskURL(rawURL), 141 - HasSecret: secret != "", 142 - CreatedAt: createdAt, 143 - }) 144 - } 145 - if configs == nil { 146 - configs = []webhookConfig{} 147 - } 148 - return configs, nil 149 - } 150 - 151 - // AddWebhookConfig creates a new webhook: stores secret in SQLite, record in PDS 152 - func (sb *ScanBroadcaster) AddWebhookConfig(userDID, webhookURL, secret string, triggers int) (string, cid.Cid, error) { 153 - ctx := context.Background() 154 - 155 - // Use TID for rkey — avoids collisions after delete+re-add 156 - rkey := sb.pds.repomgr.NextTID() 157 - 158 - // Create PDS record 159 - record := atproto.NewHoldWebhookRecord(userDID, triggers) 160 - _, recordCID, err := sb.pds.repomgr.PutRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey, record) 161 - if err != nil { 162 - return "", cid.Undef, fmt.Errorf("failed to create webhook PDS record: %w", err) 163 - } 164 - 165 - // Store secret in SQLite 166 - _, err = sb.db.Exec(` 167 - INSERT INTO webhook_secrets (rkey, user_did, url, secret) VALUES (?, ?, ?, ?) 168 - `, rkey, userDID, webhookURL, secret) 169 - if err != nil { 170 - // Try to clean up PDS record on SQLite failure 171 - _ = sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey) 172 - return "", cid.Undef, fmt.Errorf("failed to store webhook secret: %w", err) 173 - } 174 - 175 - return rkey, recordCID, nil 176 - } 177 - 178 - // DeleteWebhookConfig deletes a webhook by rkey (validates ownership) 179 - func (sb *ScanBroadcaster) DeleteWebhookConfig(userDID, rkey string) error { 180 - ctx := context.Background() 181 - 182 - // Validate ownership 183 - var owner string 184 - err := sb.db.QueryRow(`SELECT user_did FROM webhook_secrets WHERE rkey = ?`, rkey).Scan(&owner) 185 - if err != nil { 186 - return fmt.Errorf("webhook not found") 187 - } 188 - if owner != userDID { 189 - return fmt.Errorf("unauthorized: webhook belongs to a different user") 190 - } 191 - 192 - // Delete SQLite row 193 - if _, err := sb.db.Exec(`DELETE FROM webhook_secrets WHERE rkey = ?`, rkey); err != nil { 194 - return fmt.Errorf("failed to delete webhook secret: %w", err) 195 - } 196 - 197 - // Delete PDS record 198 - if err := sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey); err != nil { 199 - slog.Warn("Failed to delete webhook PDS record (secret already removed)", "rkey", rkey, "error", err) 200 - } 201 - 202 - return nil 203 - } 204 - 205 - // GetWebhooksForUser returns all active webhooks with secrets for dispatch 206 - func (sb *ScanBroadcaster) GetWebhooksForUser(userDID string) ([]activeWebhook, error) { 207 - rows, err := sb.db.Query(` 208 - SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ? 209 - `, userDID) 210 - if err != nil { 211 - return nil, err 212 - } 213 - defer rows.Close() 214 - 215 - var webhooks []activeWebhook 216 - for rows.Next() { 217 - var w activeWebhook 218 - if err := rows.Scan(&w.Rkey, &w.URL, &w.Secret); err != nil { 219 - continue 220 - } 221 - 222 - // Get triggers from PDS record 223 - _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, w.Rkey, cid.Undef) 224 - if err == nil { 225 - if rec, ok := val.(*atproto.HoldWebhookRecord); ok { 226 - w.Triggers = int(rec.Triggers) 227 - } 228 - } 229 - 230 - webhooks = append(webhooks, w) 231 - } 232 - return webhooks, nil 233 - } 234 - 235 - // dispatchWebhooks fires matching webhooks after a scan completes 236 - func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle string, summary *VulnerabilitySummary, previousScan *atproto.ScanRecord) { 237 - webhooks, err := sb.GetWebhooksForUser(userDID) 238 - if err != nil || len(webhooks) == 0 { 239 - return 240 - } 241 - 242 - isFirst := previousScan == nil 243 - isChanged := previousScan != nil && vulnCountsChanged(summary, previousScan) 244 - 245 - scanInfo := WebhookScanInfo{ 246 - ScannedAt: time.Now().Format(time.RFC3339), 247 - ScannerVersion: "atcr-scanner-v1.0.0", 248 - Vulnerabilities: WebhookVulnCounts{ 249 - Critical: summary.Critical, 250 - High: summary.High, 251 - Medium: summary.Medium, 252 - Low: summary.Low, 253 - Total: summary.Total, 254 - }, 255 - } 256 - 257 - manifestInfo := WebhookManifestInfo{ 258 - Digest: manifestDigest, 259 - Repository: repository, 260 - Tag: tag, 261 - UserDID: userDID, 262 - UserHandle: userHandle, 263 - } 264 - 265 - for _, wh := range webhooks { 266 - // Check each trigger condition 267 - triggers := []string{} 268 - if wh.Triggers&atproto.TriggerFirst != 0 && isFirst { 269 - triggers = append(triggers, "scan:first") 270 - } 271 - if wh.Triggers&atproto.TriggerAll != 0 { 272 - triggers = append(triggers, "scan:all") 273 - } 274 - if wh.Triggers&atproto.TriggerChanged != 0 && isChanged { 275 - triggers = append(triggers, "scan:changed") 276 - } 277 - 278 - for _, trigger := range triggers { 279 - payload := WebhookPayload{ 280 - Trigger: trigger, 281 - HoldDID: sb.holdDID, 282 - HoldEndpoint: sb.holdEndpoint, 283 - Manifest: manifestInfo, 284 - Scan: scanInfo, 285 - } 286 - 287 - // Include previous counts for scan:changed 288 - if trigger == "scan:changed" && previousScan != nil { 289 - payload.Previous = &WebhookVulnCounts{ 290 - Critical: int(previousScan.Critical), 291 - High: int(previousScan.High), 292 - Medium: int(previousScan.Medium), 293 - Low: int(previousScan.Low), 294 - Total: int(previousScan.Total), 295 - } 296 - } 297 - 298 - payloadBytes, err := json.Marshal(payload) 299 - if err != nil { 300 - slog.Error("Failed to marshal webhook payload", "error", err) 301 - continue 302 - } 303 - 304 - go sb.deliverWithRetry(wh.URL, wh.Secret, payloadBytes) 305 - } 306 - } 307 - } 308 - 309 - // deliverWithRetry attempts to deliver a webhook with exponential backoff 310 - func (sb *ScanBroadcaster) deliverWithRetry(webhookURL, secret string, payload []byte) { 311 - delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute} 312 - for attempt, delay := range delays { 313 - if attempt > 0 { 314 - time.Sleep(delay) 315 - } 316 - if sb.attemptDelivery(webhookURL, secret, payload) { 317 - return 318 - } 319 - } 320 - slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL)) 321 - } 322 - 323 - // attemptDelivery sends a single webhook HTTP POST 324 - func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []byte) bool { 325 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 326 - defer cancel() 327 - 328 - // Reformat payload for platform-specific webhook APIs 329 - meta := sb.pds.AppviewMeta() 330 - sendPayload := payload 331 - if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) { 332 - var p WebhookPayload 333 - if err := json.Unmarshal(payload, &p); err == nil { 334 - var formatted []byte 335 - var fmtErr error 336 - if isDiscordWebhook(webhookURL) { 337 - formatted, fmtErr = formatDiscordPayload(p, meta) 338 - } else { 339 - formatted, fmtErr = formatSlackPayload(p, meta) 340 - } 341 - if fmtErr == nil { 342 - sendPayload = formatted 343 - } 344 - } 345 - } 346 - 347 - req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(sendPayload))) 348 - if err != nil { 349 - slog.Warn("Failed to create webhook request", "error", err) 350 - return false 351 - } 352 - 353 - req.Header.Set("Content-Type", "application/json") 354 - req.Header.Set("User-Agent", meta.ClientShortName+"-Webhook/1.0") 355 - 356 - // HMAC signing if secret is set (signs the actual payload sent) 357 - if secret != "" { 358 - mac := hmac.New(sha256.New, []byte(secret)) 359 - mac.Write(sendPayload) 360 - sig := hex.EncodeToString(mac.Sum(nil)) 361 - req.Header.Set("X-Webhook-Signature-256", "sha256="+sig) 362 - } 363 - 364 - client := &http.Client{Timeout: 10 * time.Second} 365 - resp, err := client.Do(req) 366 - if err != nil { 367 - slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err) 368 - return false 369 - } 370 - defer resp.Body.Close() 371 - 372 - if resp.StatusCode >= 200 && resp.StatusCode < 300 { 373 - slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode) 374 - return true 375 - } 376 - 377 - // Read response body for debugging (e.g., Discord returns error details) 378 - body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) 379 - slog.Warn("Webhook delivery got non-2xx response", 380 - "url", maskURL(webhookURL), 381 - "status", resp.StatusCode, 382 - "body", string(body)) 383 - return false 384 - } 385 - 386 - // vulnCountsChanged checks if vulnerability counts differ between current scan and previous 387 - func vulnCountsChanged(current *VulnerabilitySummary, previous *atproto.ScanRecord) bool { 388 - return current.Critical != int(previous.Critical) || 389 - current.High != int(previous.High) || 390 - current.Medium != int(previous.Medium) || 391 - current.Low != int(previous.Low) 392 - } 393 - 394 - // maskURL masks a URL for display (shows scheme + host, hides path/query) 395 - func maskURL(rawURL string) string { 396 - u, err := url.Parse(rawURL) 397 - if err != nil { 398 - if len(rawURL) > 30 { 399 - return rawURL[:30] + "***" 400 - } 401 - return rawURL 402 - } 403 - masked := u.Scheme + "://" + u.Host 404 - if u.Path != "" && u.Path != "/" { 405 - masked += "/***" 406 - } 407 - return masked 408 - } 409 - 410 - // isDiscordWebhook checks if the URL points to a Discord webhook endpoint 411 - func isDiscordWebhook(rawURL string) bool { 412 - u, err := url.Parse(rawURL) 413 - if err != nil { 414 - return false 415 - } 416 - return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com") 417 - } 418 - 419 - // isSlackWebhook checks if the URL points to a Slack webhook endpoint 420 - func isSlackWebhook(rawURL string) bool { 421 - u, err := url.Parse(rawURL) 422 - if err != nil { 423 - return false 424 - } 425 - return u.Host == "hooks.slack.com" 426 - } 427 - 428 - // webhookSeverityColor returns a color int based on the highest severity present 429 - func webhookSeverityColor(vulns WebhookVulnCounts) int { 430 - switch { 431 - case vulns.Critical > 0: 432 - return 0xED4245 // red 433 - case vulns.High > 0: 434 - return 0xFFA500 // orange 435 - case vulns.Medium > 0: 436 - return 0xFEE75C // yellow 437 - case vulns.Low > 0: 438 - return 0x57F287 // green 439 - default: 440 - return 0x95A5A6 // grey 441 - } 442 - } 443 - 444 - // webhookSeverityHex returns a hex color string (e.g., "#ED4245") 445 - func webhookSeverityHex(vulns WebhookVulnCounts) string { 446 - return fmt.Sprintf("#%06X", webhookSeverityColor(vulns)) 447 - } 448 - 449 - // formatVulnDescription builds a vulnerability summary with colored square emojis 450 - func formatVulnDescription(v WebhookVulnCounts, digest string) string { 451 - var lines []string 452 - 453 - if len(digest) > 19 { 454 - lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"...")) 455 - } 456 - 457 - if v.Total == 0 { 458 - lines = append(lines, "🟩 No vulnerabilities found") 459 - } else { 460 - if v.Critical > 0 { 461 - lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical)) 462 - } 463 - if v.High > 0 { 464 - lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High)) 465 - } 466 - if v.Medium > 0 { 467 - lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium)) 468 - } 469 - if v.Low > 0 { 470 - lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low)) 471 - } 472 - } 473 - 474 - return strings.Join(lines, "\n") 475 - } 476 - 477 - // formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format 478 - func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 479 - appviewURL := meta.BaseURL 480 - title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag) 481 - 482 - description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest) 483 - 484 - // Add previous counts for scan:changed 485 - if p.Trigger == "scan:changed" && p.Previous != nil { 486 - description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d", 487 - p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low) 488 - } 489 - 490 - embed := map[string]any{ 491 - "title": title, 492 - "url": appviewURL, 493 - "description": description, 494 - "color": webhookSeverityColor(p.Scan.Vulnerabilities), 495 - "footer": map[string]string{ 496 - "text": meta.ClientShortName, 497 - "icon_url": meta.FaviconURL, 498 - }, 499 - "timestamp": p.Scan.ScannedAt, 500 - } 501 - 502 - // Add author, repo link, and OG image when handle is available 503 - if p.Manifest.UserHandle != "" { 504 - embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 505 - embed["author"] = map[string]string{ 506 - "name": p.Manifest.UserHandle, 507 - "url": appviewURL + "/u/" + p.Manifest.UserHandle, 508 - } 509 - embed["image"] = map[string]string{ 510 - "url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository), 511 - } 512 - } else { 513 - embed["image"] = map[string]string{ 514 - "url": appviewURL + "/og/home", 515 - } 516 - } 517 - 518 - payload := map[string]any{ 519 - "username": meta.ClientShortName, 520 - "avatar_url": meta.FaviconURL, 521 - "embeds": []any{embed}, 522 - } 523 - return json.Marshal(payload) 524 - } 525 - 526 - // formatSlackPayload wraps an ATCR webhook payload in Slack's message format 527 - func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 528 - appviewURL := meta.BaseURL 529 - title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag) 530 - 531 - v := p.Scan.Vulnerabilities 532 - fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low", 533 - title, v.Critical, v.High, v.Medium, v.Low) 534 - 535 - description := formatVulnDescription(v, p.Manifest.Digest) 536 - 537 - // Add previous counts for scan:changed 538 - if p.Trigger == "scan:changed" && p.Previous != nil { 539 - description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d", 540 - p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low) 541 - } 542 - 543 - attachment := map[string]any{ 544 - "fallback": fallback, 545 - "color": webhookSeverityHex(v), 546 - "title": title, 547 - "text": description, 548 - "footer": meta.ClientShortName, 549 - "footer_icon": meta.FaviconURL, 550 - "ts": p.Scan.ScannedAt, 551 - } 552 - 553 - // Add repo link when handle is available 554 - if p.Manifest.UserHandle != "" { 555 - attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 556 - attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 557 - attachment["author_name"] = p.Manifest.UserHandle 558 - attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle 559 - } 560 - 561 - payload := map[string]any{ 562 - "text": fallback, 563 - "attachments": []any{attachment}, 564 - } 565 - return json.Marshal(payload) 566 - } 567 - 568 - // isCaptain checks if the given DID is the hold captain (owner) 569 - func (h *XRPCHandler) isCaptain(ctx context.Context, did string) bool { 570 - _, captain, err := h.pds.GetCaptainRecord(ctx) 571 - if err != nil { 572 - slog.Debug("isCaptain: failed to get captain record", "error", err) 573 - return false 574 - } 575 - if captain == nil { 576 - slog.Debug("isCaptain: captain record is nil") 577 - return false 578 - } 579 - match := captain.Owner == did 580 - if !match { 581 - slog.Debug("isCaptain: DID mismatch", "captain.Owner", captain.Owner, "user.DID", did) 582 - } 583 - return match 584 - } 585 - 586 - // ---- XRPC Handlers ---- 587 - 588 - // HandleListWebhooks returns webhook configs for a user 589 - func (h *XRPCHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) { 590 - user := getUserFromContext(r) 591 - if user == nil { 592 - http.Error(w, "authentication required", http.StatusUnauthorized) 593 - return 594 - } 595 - 596 - if h.scanBroadcaster == nil { 597 - render.JSON(w, r, map[string]any{ 598 - "webhooks": []any{}, 599 - "limits": map[string]any{"max": 0, "allTriggers": false}, 600 - }) 601 - return 602 - } 603 - 604 - configs, err := h.scanBroadcaster.ListWebhookConfigs(user.DID) 605 - if err != nil { 606 - http.Error(w, fmt.Sprintf("failed to list webhooks: %v", err), http.StatusInternalServerError) 607 - return 608 - } 609 - 610 - // Get tier limits — captains get unlimited access 611 - maxWebhooks, allTriggers := 1, false 612 - if h.isCaptain(r.Context(), user.DID) { 613 - maxWebhooks, allTriggers = -1, true 614 - } else if h.quotaMgr != nil { 615 - _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 616 - tierKey := "" 617 - if crew != nil { 618 - tierKey = crew.Tier 619 - } 620 - maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey) 621 - } 622 - 623 - render.JSON(w, r, map[string]any{ 624 - "webhooks": configs, 625 - "limits": map[string]any{ 626 - "max": maxWebhooks, 627 - "allTriggers": allTriggers, 628 - }, 629 - }) 630 - } 631 - 632 - // HandleAddWebhook creates a new webhook configuration 633 - func (h *XRPCHandler) HandleAddWebhook(w http.ResponseWriter, r *http.Request) { 634 - user := getUserFromContext(r) 635 - if user == nil { 636 - http.Error(w, "authentication required", http.StatusUnauthorized) 637 - return 638 - } 639 - 640 - if h.scanBroadcaster == nil { 641 - http.Error(w, "scanning not enabled", http.StatusNotImplemented) 642 - return 643 - } 644 - 645 - var req struct { 646 - URL string `json:"url"` 647 - Secret string `json:"secret"` 648 - Triggers int `json:"triggers"` 649 - } 650 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 651 - http.Error(w, "invalid request body", http.StatusBadRequest) 652 - return 653 - } 654 - 655 - // Validate HTTPS URL 656 - u, err := url.Parse(req.URL) 657 - if err != nil || (u.Scheme != "https" && u.Scheme != "http") { 658 - http.Error(w, "invalid webhook URL: must be https", http.StatusBadRequest) 659 - return 660 - } 661 - 662 - // Tier enforcement — captains get unlimited access 663 - maxWebhooks, allTriggers := 1, false 664 - if h.isCaptain(r.Context(), user.DID) { 665 - maxWebhooks, allTriggers = -1, true 666 - } else { 667 - tierKey := "" 668 - _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID) 669 - if crew != nil { 670 - tierKey = crew.Tier 671 - } 672 - if h.quotaMgr != nil { 673 - maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey) 674 - } 675 - } 676 - 677 - // Check webhook count limit 678 - count, err := h.scanBroadcaster.CountWebhooks(user.DID) 679 - if err != nil { 680 - http.Error(w, "failed to check webhook count", http.StatusInternalServerError) 681 - return 682 - } 683 - if maxWebhooks >= 0 && count >= maxWebhooks { 684 - http.Error(w, fmt.Sprintf("webhook limit reached (%d/%d)", count, maxWebhooks), http.StatusForbidden) 685 - return 686 - } 687 - 688 - // Trigger bitmask enforcement: free users can only set TriggerFirst 689 - if !allTriggers && req.Triggers & ^atproto.TriggerFirst != 0 { 690 - http.Error(w, "trigger types beyond scan:first require a paid tier", http.StatusForbidden) 691 - return 692 - } 693 - 694 - rkey, recordCID, err := h.scanBroadcaster.AddWebhookConfig(user.DID, req.URL, req.Secret, req.Triggers) 695 - if err != nil { 696 - http.Error(w, fmt.Sprintf("failed to add webhook: %v", err), http.StatusInternalServerError) 697 - return 698 - } 699 - 700 - render.Status(r, http.StatusCreated) 701 - render.JSON(w, r, map[string]any{ 702 - "rkey": rkey, 703 - "cid": recordCID.String(), 704 - }) 705 - } 706 - 707 - // HandleDeleteWebhook deletes a webhook configuration 708 - func (h *XRPCHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) { 709 - user := getUserFromContext(r) 710 - if user == nil { 711 - http.Error(w, "authentication required", http.StatusUnauthorized) 712 - return 713 - } 714 - 715 - if h.scanBroadcaster == nil { 716 - http.Error(w, "scanning not enabled", http.StatusNotImplemented) 717 - return 718 - } 719 - 720 - var req struct { 721 - Rkey string `json:"rkey"` 722 - } 723 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 724 - http.Error(w, "invalid request body", http.StatusBadRequest) 725 - return 726 - } 727 - 728 - if err := h.scanBroadcaster.DeleteWebhookConfig(user.DID, req.Rkey); err != nil { 729 - if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") { 730 - http.Error(w, err.Error(), http.StatusForbidden) 731 - } else { 732 - http.Error(w, fmt.Sprintf("failed to delete webhook: %v", err), http.StatusInternalServerError) 733 - } 734 - return 735 - } 736 - 737 - render.JSON(w, r, map[string]any{"success": true}) 738 - } 739 - 740 - // HandleTestWebhook sends a test payload to a webhook 741 - func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) { 742 - user := getUserFromContext(r) 743 - if user == nil { 744 - http.Error(w, "authentication required", http.StatusUnauthorized) 745 - return 746 - } 747 - 748 - if h.scanBroadcaster == nil { 749 - http.Error(w, "scanning not enabled", http.StatusNotImplemented) 750 - return 751 - } 752 - 753 - var req struct { 754 - Rkey string `json:"rkey"` 755 - } 756 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 757 - http.Error(w, "invalid request body", http.StatusBadRequest) 758 - return 759 - } 760 - 761 - // Look up webhook URL and secret 762 - var webhookURL, secret, owner string 763 - err := h.scanBroadcaster.db.QueryRow(` 764 - SELECT url, secret, user_did FROM webhook_secrets WHERE rkey = ? 765 - `, req.Rkey).Scan(&webhookURL, &secret, &owner) 766 - if err != nil { 767 - http.Error(w, "webhook not found", http.StatusNotFound) 768 - return 769 - } 770 - if owner != user.DID { 771 - http.Error(w, "unauthorized", http.StatusForbidden) 772 - return 773 - } 774 - 775 - // Resolve handle if not available from auth context 776 - userHandle := user.Handle 777 - if userHandle == "" { 778 - if _, handle, _, err := atproto.ResolveIdentity(r.Context(), user.DID); err == nil { 779 - userHandle = handle 780 - } 781 - } 782 - 783 - // Randomize vulnerability counts so each test shows a different severity color 784 - critical := rand.IntN(3) 785 - high := rand.IntN(5) 786 - medium := rand.IntN(8) 787 - low := rand.IntN(10) 788 - total := critical + high + medium + low 789 - 790 - // Build test payload 791 - payload := WebhookPayload{ 792 - Trigger: "test", 793 - HoldDID: h.scanBroadcaster.holdDID, 794 - HoldEndpoint: h.scanBroadcaster.holdEndpoint, 795 - Manifest: WebhookManifestInfo{ 796 - Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", 797 - Repository: "test-repo", 798 - Tag: "latest", 799 - UserDID: user.DID, 800 - UserHandle: userHandle, 801 - }, 802 - Scan: WebhookScanInfo{ 803 - ScannedAt: time.Now().Format(time.RFC3339), 804 - ScannerVersion: "atcr-scanner-v1.0.0", 805 - Vulnerabilities: WebhookVulnCounts{ 806 - Critical: critical, High: high, Medium: medium, Low: low, Total: total, 807 - }, 808 - }, 809 - } 810 - 811 - payloadBytes, _ := json.Marshal(payload) 812 - 813 - // Deliver test payload synchronously 814 - success := h.scanBroadcaster.attemptDelivery(webhookURL, secret, payloadBytes) 815 - 816 - render.JSON(w, r, map[string]any{ 817 - "success": success, 818 - }) 819 - } 820 - 821 - // registerWebhookHandlers registers webhook XRPC handlers on the router. 822 - // Called from RegisterHandlers. 823 - func (h *XRPCHandler) registerWebhookHandlers(r chi.Router) { 824 - r.Group(func(r chi.Router) { 825 - r.Use(h.requireAuth) 826 - r.Get(atproto.HoldListWebhooks, h.HandleListWebhooks) 827 - r.Post(atproto.HoldAddWebhook, h.HandleAddWebhook) 828 - r.Post(atproto.HoldDeleteWebhook, h.HandleDeleteWebhook) 829 - r.Post(atproto.HoldTestWebhook, h.HandleTestWebhook) 830 - }) 831 - }
+116 -2
pkg/hold/pds/xrpc.go
··· 49 49 scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners 50 50 httpClient HTTPClient // For testing - allows injecting mock HTTP client 51 51 quotaMgr *quota.Manager // Quota manager for tier-based limits 52 + appviewDID string // DID of the trusted appview (for tier updates) 52 53 } 53 54 54 55 // PartInfo represents a completed part in a multipart upload ··· 74 75 httpClient: httpClient, 75 76 quotaMgr: quotaMgr, 76 77 } 78 + } 79 + 80 + // SetAppviewDID sets the trusted appview DID for tier update authentication. 81 + func (h *XRPCHandler) SetAppviewDID(did string) { 82 + h.appviewDID = did 77 83 } 78 84 79 85 // SetScanBroadcaster sets the scan broadcaster for dispatching scan jobs to scanners ··· 209 215 // Public quota endpoint (no auth - quota is per-user, just needs userDid param) 210 216 r.Get(atproto.HoldGetQuota, h.HandleGetQuota) 211 217 218 + // Public tier list endpoint (no auth) 219 + r.Get(atproto.HoldListTiers, h.HandleListTiers) 220 + 221 + // Appview-authenticated endpoints (appview JWT auth) 222 + r.Post(atproto.HoldUpdateCrewTier, h.HandleUpdateCrewTier) 223 + 212 224 // Scanner WebSocket endpoint (shared secret auth) 213 225 r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs) 214 226 215 - // Webhook management endpoints (service token auth) 216 - h.registerWebhookHandlers(r) 217 227 } 218 228 219 229 // HandleHealth returns health check information ··· 1602 1612 } 1603 1613 1604 1614 render.JSON(w, r, stats) 1615 + } 1616 + 1617 + // HandleListTiers returns the hold's available tiers with storage quotas. 1618 + // This is a public endpoint (no auth) so the appview UI can display "3-10 GB depending on region." 1619 + func (h *XRPCHandler) HandleListTiers(w http.ResponseWriter, r *http.Request) { 1620 + if !h.quotaMgr.IsEnabled() { 1621 + render.JSON(w, r, map[string]any{"tiers": []any{}}) 1622 + return 1623 + } 1624 + 1625 + tierInfos := h.quotaMgr.ListTiers() 1626 + tiers := make([]map[string]any, 0, len(tierInfos)) 1627 + for _, t := range tierInfos { 1628 + var quotaBytes int64 1629 + if t.Limit != nil { 1630 + quotaBytes = *t.Limit 1631 + } 1632 + tiers = append(tiers, map[string]any{ 1633 + "name": t.Key, 1634 + "quotaBytes": quotaBytes, 1635 + "quotaFormatted": quota.FormatHumanBytes(quotaBytes), 1636 + "scanOnPush": t.ScanOnPush, 1637 + }) 1638 + } 1639 + 1640 + render.JSON(w, r, map[string]any{"tiers": tiers}) 1641 + } 1642 + 1643 + // HandleUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview. 1644 + // Auth: Bearer token signed by the appview's P-256 key (ES256 JWT). 1645 + func (h *XRPCHandler) HandleUpdateCrewTier(w http.ResponseWriter, r *http.Request) { 1646 + if h.appviewDID == "" { 1647 + http.Error(w, "appview DID not configured on this hold", http.StatusServiceUnavailable) 1648 + return 1649 + } 1650 + 1651 + // Validate appview token 1652 + userDID, err := ValidateAppviewToken(r, h.appviewDID, h.pds.DID()) 1653 + if err != nil { 1654 + slog.Warn("Appview token validation failed for updateCrewTier", "error", err) 1655 + http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized) 1656 + return 1657 + } 1658 + 1659 + // Parse request body 1660 + var req struct { 1661 + UserDID string `json:"userDid"` 1662 + TierRank int `json:"tierRank"` 1663 + } 1664 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 1665 + http.Error(w, "invalid request body", http.StatusBadRequest) 1666 + return 1667 + } 1668 + 1669 + // Verify the userDid in the body matches the sub claim 1670 + if req.UserDID != "" && req.UserDID != userDID { 1671 + // Use the body's userDid (the sub claim was the appview-specified user) 1672 + userDID = req.UserDID 1673 + } 1674 + 1675 + if !atproto.IsDID(userDID) { 1676 + http.Error(w, "invalid userDid format", http.StatusBadRequest) 1677 + return 1678 + } 1679 + 1680 + // Map tier rank to tier name 1681 + tierName := h.resolveTierByRank(req.TierRank) 1682 + if tierName == "" { 1683 + http.Error(w, "no tiers configured on this hold", http.StatusBadRequest) 1684 + return 1685 + } 1686 + 1687 + // Update the crew member's tier 1688 + if err := h.pds.UpdateCrewMemberTier(r.Context(), userDID, tierName); err != nil { 1689 + slog.Error("Failed to update crew tier", "userDid", userDID, "tier", tierName, "error", err) 1690 + http.Error(w, fmt.Sprintf("failed to update tier: %v", err), http.StatusInternalServerError) 1691 + return 1692 + } 1693 + 1694 + slog.Info("Updated crew tier via appview", "userDid", userDID, "tierRank", req.TierRank, "tierName", tierName) 1695 + 1696 + render.JSON(w, r, map[string]string{"tierName": tierName}) 1697 + } 1698 + 1699 + // resolveTierByRank maps a 0-based rank index to a tier name from the quota config. 1700 + // If the rank exceeds the number of tiers, it clamps to the highest tier. 1701 + func (h *XRPCHandler) resolveTierByRank(rank int) string { 1702 + if !h.quotaMgr.IsEnabled() { 1703 + return "" 1704 + } 1705 + 1706 + tiers := h.quotaMgr.ListTiers() 1707 + if len(tiers) == 0 { 1708 + return "" 1709 + } 1710 + 1711 + if rank < 0 { 1712 + rank = 0 1713 + } 1714 + if rank >= len(tiers) { 1715 + rank = len(tiers) - 1 1716 + } 1717 + 1718 + return tiers[rank].Key 1605 1719 } 1606 1720 1607 1721 // HoldUserDataExport represents the GDPR data export from a hold service
+6 -69
pkg/hold/quota/config.go
··· 40 40 41 41 // Whether pushing triggers an immediate vulnerability scan. 42 42 ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."` 43 - 44 - // Maximum number of webhook URLs a user can configure. 0 = none, -1 = unlimited. 45 - MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhook URLs (0=none, -1=unlimited). Default: 1."` 46 - 47 - // Whether all trigger types are allowed. Free tiers only get scan:first. 48 - WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types. Free tiers only get scan:first."` 49 - 50 - // Whether this tier earns a supporter badge on user profiles. 51 - SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for members at this tier."` 52 43 } 53 44 54 45 // DefaultsConfig represents default settings. 55 46 type DefaultsConfig struct { 56 47 // Name of the tier assigned to new crew members. 57 48 NewCrewTier string `yaml:"new_crew_tier" comment:"Tier assigned to new crew members who don't have an explicit tier."` 58 - 59 - // Whether the hold owner (captain) gets a supporter badge on their profile. 60 - OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on the hold owner's profile."` 61 49 } 62 50 63 51 // Manager manages quota configuration and tier resolution ··· 220 208 return false 221 209 } 222 210 223 - // WebhookLimits returns the webhook limits for a tier. 224 - // Returns (maxWebhooks, allTriggers). Default when no config: (1, false). 225 - // Follows the same fallback logic as GetTierLimit. 226 - func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bool) { 227 - if !m.IsEnabled() { 228 - return 1, false 229 - } 230 - 231 - if tierKey != "" { 232 - if tier := m.config.TierByName(tierKey); tier != nil { 233 - max := tier.MaxWebhooks 234 - if max == 0 { 235 - max = 1 // default 236 - } 237 - return max, tier.WebhookAllTriggers 238 - } 239 - } 240 - 241 - // Fall back to default tier 242 - if m.config.Defaults.NewCrewTier != "" { 243 - if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil { 244 - max := tier.MaxWebhooks 245 - if max == 0 { 246 - max = 1 247 - } 248 - return max, tier.WebhookAllTriggers 249 - } 250 - } 251 - 252 - return 1, false 253 - } 254 - 255 - // BadgeTiers returns the names of tiers that have supporter badges enabled, 256 - // ordered from highest rank to lowest. Includes "owner" first if 257 - // defaults.owner_badge is true. 258 - // Returns nil if quotas are disabled or no tiers have badges. 259 - func (m *Manager) BadgeTiers() []string { 260 - if !m.IsEnabled() { 261 - return nil 262 - } 263 - var tiers []string 264 - if m.config.Defaults.OwnerBadge { 265 - tiers = append(tiers, "owner") 266 - } 267 - // Iterate in reverse: highest rank first 268 - for i := len(m.config.Tiers) - 1; i >= 0; i-- { 269 - if m.config.Tiers[i].SupporterBadge { 270 - tiers = append(tiers, m.config.Tiers[i].Name) 271 - } 272 - } 273 - return tiers 274 - } 275 - 276 211 // TierCount returns the number of configured tiers 277 212 func (m *Manager) TierCount() int { 278 213 return len(m.tiers) ··· 280 215 281 216 // TierInfo represents tier information for listing 282 217 type TierInfo struct { 283 - Key string 284 - Limit *int64 218 + Key string 219 + Limit *int64 220 + ScanOnPush bool 285 221 } 286 222 287 223 // ListTiers returns all configured tiers with their limits, in rank order ··· 299 235 } 300 236 limitCopy := limit 301 237 tiers = append(tiers, TierInfo{ 302 - Key: tier.Name, 303 - Limit: &limitCopy, 238 + Key: tier.Name, 239 + Limit: &limitCopy, 240 + ScanOnPush: tier.ScanOnPush, 304 241 }) 305 242 } 306 243 return tiers
-27
pkg/hold/quota/config_test.go
··· 427 427 } 428 428 } 429 429 430 - func TestBadgeTiers_RankOrder(t *testing.T) { 431 - cfg := &Config{ 432 - Tiers: []TierConfig{ 433 - {Name: "deckhand", Quota: "5GB"}, 434 - {Name: "bosun", Quota: "50GB", SupporterBadge: true}, 435 - {Name: "quartermaster", Quota: "100GB", SupporterBadge: true}, 436 - }, 437 - Defaults: DefaultsConfig{OwnerBadge: true}, 438 - } 439 - m, err := NewManagerFromConfig(cfg) 440 - if err != nil { 441 - t.Fatal(err) 442 - } 443 - 444 - tiers := m.BadgeTiers() 445 - // Expected: owner first, then highest rank first 446 - expected := []string{"owner", "quartermaster", "bosun"} 447 - if len(tiers) != len(expected) { 448 - t.Fatalf("got %v, want %v", tiers, expected) 449 - } 450 - for i := range expected { 451 - if tiers[i] != expected[i] { 452 - t.Errorf("tiers[%d] = %q, want %q", i, tiers[i], expected[i]) 453 - } 454 - } 455 - } 456 - 457 430 func TestListTiers_PreservesOrder(t *testing.T) { 458 431 cfg := &Config{ 459 432 Tiers: []TierConfig{
+18 -51
pkg/hold/server.go
··· 12 12 13 13 "atcr.io/pkg/atproto" 14 14 "atcr.io/pkg/hold/admin" 15 - "atcr.io/pkg/hold/billing" 16 15 holddb "atcr.io/pkg/hold/db" 17 16 "atcr.io/pkg/hold/gc" 18 17 "atcr.io/pkg/hold/oci" ··· 105 104 } 106 105 107 106 // Use shared DB for all subsystems 108 - s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts, s.holdDB.DB) 107 + s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts, s.holdDB.DB) 109 108 if err != nil { 110 109 return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err) 111 110 } ··· 113 112 s.broadcaster = pds.NewEventBroadcasterWithDB(holdDID, 100, s.holdDB.DB) 114 113 } else { 115 114 // In-memory mode (tests): each subsystem opens its own connection 116 - s.PDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts) 115 + s.PDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts) 117 116 if err != nil { 118 117 return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err) 119 118 } ··· 188 187 slog.Info("Quota enforcement disabled (no quota tiers configured)") 189 188 } 190 189 191 - // Sync supporter badge tiers from quota config into captain record 192 - if s.PDS != nil { 193 - badgeTiers := s.QuotaManager.BadgeTiers() 194 - badgeCtx := context.Background() 195 - if _, captain, err := s.PDS.GetCaptainRecord(badgeCtx); err == nil { 196 - if !stringSlicesEqual(captain.SupporterBadgeTiers, badgeTiers) { 197 - captain.SupporterBadgeTiers = badgeTiers 198 - if _, err := s.PDS.UpdateCaptainRecord(badgeCtx, captain); err != nil { 199 - slog.Warn("Failed to sync supporter badge tiers", "error", err) 200 - } else { 201 - slog.Info("Synced supporter badge tiers from quota config", "tiers", badgeTiers) 202 - } 203 - } 204 - } 205 - } 206 - 207 190 // Create XRPC handlers 208 191 var ociHandler *oci.XRPCHandler 209 192 if s.PDS != nil { 210 193 xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, s.broadcaster, nil, s.QuotaManager) 194 + if cfg.Server.AppviewDID != "" { 195 + xrpcHandler.SetAppviewDID(cfg.Server.AppviewDID) 196 + } 211 197 ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager) 212 198 213 199 // Initialize scan broadcaster if scanner secret is configured ··· 240 226 // Setup HTTP routes with chi router 241 227 r := chi.NewRouter() 242 228 r.Use(middleware.RealIP) 243 - r.Use(middleware.Logger) 229 + r.Use(middleware.Maybe(middleware.Logger, func(r *http.Request) bool { 230 + return r.URL.Path != "/xrpc/_health" 231 + })) 244 232 245 233 if xrpcHandler != nil { 246 234 r.Use(xrpcHandler.CORSMiddleware()) ··· 250 238 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 251 239 w.Header().Set("Content-Type", "text/plain") 252 240 fmt.Fprintf(w, "This is a hold server. More info at https://atcr.io") 241 + }) 242 + 243 + // Robots.txt - disallow crawling of all endpoints except root 244 + r.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) { 245 + w.Header().Set("Content-Type", "text/plain") 246 + fmt.Fprint(w, "User-agent: *\nAllow: /\nDisallow: /xrpc/\nDisallow: /admin/\n") 253 247 }) 254 248 255 249 // Register XRPC/ATProto PDS endpoints ··· 283 277 } 284 278 } 285 279 286 - // Initialize billing manager (compile-time optional via -tags billing) 287 - billingMgr := billing.New(s.QuotaManager, cfg.Server.PublicURL, cfg.ConfigPath()) 288 - if billingMgr.Enabled() { 289 - slog.Info("Billing enabled (Stripe integration active)") 290 - } else { 291 - slog.Info("Billing disabled (not compiled or not configured)") 292 - } 293 - 294 - // Register billing endpoints (if configured and PDS available) 295 - if s.PDS != nil && billingMgr.Enabled() { 296 - billingHandler := billing.NewXRPCHandler(billingMgr, s.PDS, http.DefaultClient) 297 - billingHandler.RegisterHandlers(r) 298 - } 299 - 300 280 s.Router = r 301 281 302 282 return s, nil ··· 334 314 } 335 315 } 336 316 337 - // Fetch appview metadata for branding (webhook embeds, posts) 338 - if s.Config.Server.AppviewURL != "" { 339 - meta, err := atproto.FetchAppviewMetadata(context.Background(), s.Config.Server.AppviewURL) 317 + // Fetch appview metadata for branding (Bluesky posts) 318 + if s.Config.Server.AppviewURL() != "" { 319 + meta, err := atproto.FetchAppviewMetadata(context.Background(), s.Config.Server.AppviewURL()) 340 320 if err != nil { 341 - slog.Warn("Failed to fetch appview metadata, using defaults", "appview_url", s.Config.Server.AppviewURL, "error", err) 321 + slog.Warn("Failed to fetch appview metadata, using defaults", "appview_url", s.Config.Server.AppviewURL(), "error", err) 342 322 } else { 343 323 s.PDS.SetAppviewMeta(meta) 344 324 slog.Info("Fetched appview metadata", "clientName", meta.ClientName, "clientShortName", meta.ClientShortName) ··· 439 419 440 420 logging.Shutdown() 441 421 } 442 - 443 - // stringSlicesEqual returns true if two string slices have the same elements. 444 - func stringSlicesEqual(a, b []string) bool { 445 - if len(a) != len(b) { 446 - return false 447 - } 448 - for i := range a { 449 - if a[i] != b[i] { 450 - return false 451 - } 452 - } 453 - return true 454 - }
+7
scanner/cmd/scanner/main.go
··· 7 7 "net/http" 8 8 "os" 9 9 "os/signal" 10 + "runtime/debug" 10 11 "syscall" 11 12 12 13 "github.com/spf13/cobra" ··· 38 39 Environment variables always override file values (SCANNER_ prefix).`, 39 40 Args: cobra.NoArgs, 40 41 RunE: func(cmd *cobra.Command, args []string) error { 42 + // Set a soft memory limit so the GC gets aggressive before the OOM 43 + // killer intervenes. GOMEMLIMIT env var overrides this default. 44 + if os.Getenv("GOMEMLIMIT") == "" { 45 + debug.SetMemoryLimit(512 * 1024 * 1024) // 512 MiB 46 + } 47 + 41 48 cfg, err := config.LoadConfig(configFile) 42 49 if err != nil { 43 50 return fmt.Errorf("failed to load config: %w", err)
+7 -2
scanner/internal/client/hold.go
··· 18 18 "github.com/gorilla/websocket" 19 19 ) 20 20 21 + // httpClient is used for blob downloads and presigned URL requests 22 + // with a timeout to prevent stalled connections from leaking memory. 23 + var httpClient = &http.Client{Timeout: 5 * time.Minute} 24 + 21 25 // HoldClient manages the WebSocket connection to a hold service 22 26 type HoldClient struct { 23 27 holdURL string ··· 95 99 if err != nil { 96 100 return fmt.Errorf("dial failed: %w", err) 97 101 } 102 + defer conn.Close() 98 103 99 104 c.mu.Lock() 100 105 c.conn = conn ··· 229 234 req.Header.Set("Authorization", "Bearer "+secret) 230 235 } 231 236 232 - resp, err := http.DefaultClient.Do(req) 237 + resp, err := httpClient.Do(req) 233 238 if err != nil { 234 239 return "", fmt.Errorf("failed to get presigned URL: %w", err) 235 240 } ··· 252 257 253 258 // DownloadBlob downloads a blob from a presigned URL to a local file 254 259 func DownloadBlob(presignedURL, destPath string) error { 255 - resp, err := http.Get(presignedURL) 260 + resp, err := httpClient.Get(presignedURL) 256 261 if err != nil { 257 262 return fmt.Errorf("failed to download blob: %w", err) 258 263 }
+1 -1
scanner/internal/config/config.go
··· 72 72 v.SetDefault("hold.secret", "") 73 73 74 74 // Scanner defaults 75 - v.SetDefault("scanner.workers", 2) 75 + v.SetDefault("scanner.workers", 1) 76 76 v.SetDefault("scanner.queue_size", 100) 77 77 78 78 // Vuln defaults
+17 -2
scanner/internal/scan/grype.go
··· 9 9 "os" 10 10 "path/filepath" 11 11 "sync" 12 + "sync/atomic" 12 13 "time" 13 14 14 15 scanner "atcr.io/scanner" ··· 34 35 var ( 35 36 vulnDB vulnerability.Provider 36 37 vulnDBLock sync.RWMutex 37 - vulnDBLoaded time.Time // when the current vulnDB was loaded 38 + vulnDBLoaded time.Time // when the current vulnDB was loaded 39 + vulnDBScans atomic.Int64 // scan counter for periodic reload 38 40 ) 39 41 40 42 // vulnDBRefreshAge is how long a cached DB is considered fresh. ··· 140 142 141 143 // Double-check after acquiring write lock 142 144 if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge { 143 - return vulnDB, nil 145 + // Periodic reload: close and reopen DB every 50 scans to flush 146 + // SQLite's page cache and mmap region. 147 + n := vulnDBScans.Add(1) 148 + if n%50 == 0 { 149 + slog.Info("Periodic vulnDB reload to release memory", "scans", n) 150 + vulnDB.Close() 151 + vulnDB = nil 152 + // Fall through to reload below 153 + } else { 154 + return vulnDB, nil 155 + } 144 156 } 145 157 146 158 slog.Info("Loading Grype vulnerability database", "path", vulnDBPath) ··· 178 190 "built", status.Built, 179 191 "schemaVersion", status.SchemaVersion) 180 192 193 + if vulnDB != nil { 194 + vulnDB.Close() 195 + } 181 196 vulnDB = store 182 197 vulnDBLoaded = time.Now() 183 198 return vulnDB, nil
+3 -2
scanner/internal/scan/syft.go
··· 29 29 if err != nil { 30 30 return nil, nil, "", fmt.Errorf("failed to load OCI image: %w", err) 31 31 } 32 - defer img.Cleanup() 33 32 34 33 if err := img.Read(); err != nil { 34 + img.Cleanup() 35 35 return nil, nil, "", fmt.Errorf("failed to read OCI image: %w", err) 36 36 } 37 37 38 - // Wrap in Syft source 38 + // Wrap in Syft source — src.Close() calls img.Cleanup() internally, 39 + // so we don't defer img.Cleanup() separately. 39 40 src := stereoscopesource.New(img, stereoscopesource.ImageConfig{ 40 41 Reference: ociLayoutDir, 41 42 })
+20 -6
scanner/internal/scan/worker.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "os" 10 + "runtime" 10 11 "strings" 11 12 "sync" 12 13 "time" ··· 93 94 "repository", job.Repository, 94 95 "error", err) 95 96 wp.client.SendError(job.Seq, err.Error()) 96 - continue 97 + } else { 98 + wp.client.SendResult(job.Seq, result) 99 + 100 + slog.Info("Scan job completed", 101 + "worker_id", id, 102 + "repository", job.Repository, 103 + "vulnerabilities", result.Summary.Total) 97 104 } 98 105 99 - wp.client.SendResult(job.Seq, result) 106 + // Free large scan artifacts and trigger GC before the cooldown 107 + // so memory is reclaimed between jobs. Syft/Grype allocate heavily 108 + // and Go's GC needs idle time to catch up under sustained load. 109 + result = nil 110 + runtime.GC() 100 111 101 - slog.Info("Scan job completed", 102 - "worker_id", id, 103 - "repository", job.Repository, 104 - "vulnerabilities", result.Summary.Total) 112 + // Cooldown between scans to reduce sustained memory pressure 113 + select { 114 + case <-ctx.Done(): 115 + return 116 + case <-time.After(10 * time.Second): 117 + } 105 118 } 106 119 } 107 120 ··· 168 181 result.VulnDigest = vulnDigest 169 182 result.Summary = &summary 170 183 } 184 + sbomResult = nil // release SBOM catalog for GC 171 185 172 186 duration := time.Since(startTime) 173 187 slog.Info("Scan pipeline completed",