···4141 atproto.TagCollection, // io.atcr.tag
4242 atproto.SailorProfileCollection, // io.atcr.sailor.profile
4343 atproto.StarCollection, // io.atcr.sailor.star
4444- atproto.SailorWebhookCollection, // io.atcr.sailor.webhook
4544 atproto.RepoPageCollection, // io.atcr.repo.page
4645 atproto.CaptainCollection, // io.atcr.hold.captain
4746 atproto.CrewCollection, // io.atcr.hold.crew
4847 atproto.LayerCollection, // io.atcr.hold.layer
4948 atproto.StatsCollection, // io.atcr.hold.stats
5049 atproto.ScanCollection, // io.atcr.hold.scan
5151- atproto.WebhookCollection, // io.atcr.hold.webhook
5250}
53515452type summaryRow struct {
5555- collection string
5656- counts []int
5757- status string // "sync", "diff", "error"
5858- diffCount int
5959- realGaps int // verified: record exists on PDS but relay is missing it
6060- ghosts int // verified: record doesn't exist on PDS, relay has stale entry
5353+ collection string
5454+ counts []int
5555+ status string // "sync", "diff", "error"
5656+ diffCount int
5757+ realGaps int // verified: record exists on PDS but relay is missing it
5858+ ghosts int // verified: record doesn't exist on PDS, relay has stale entry
5959+ deactivated int // verified: account deactivated/deleted on PDS
6160}
62616362// verifyResult holds the PDS verification result for a (DID, collection) pair.
6463type verifyResult struct {
6565- exists bool
6666- err error
6464+ exists bool
6565+ deactivated bool // account deactivated/deleted on PDS
6666+ err error
6767}
68686969// key identifies a (collection, relay-or-DID) pair for result lookups.
···211211 totalMissing := 0
212212 totalRealGaps := 0
213213 totalGhosts := 0
214214+ totalDeactivated := 0
214215215216 for _, col := range cols {
216217 fmt.Printf("\n%s%s━━━ %s ━━━%s\n", cBold, cCyan, col, cReset)
···258259 suffix = fmt.Sprintf(" %s(verify: unknown)%s", cDim, cReset)
259260 } else if vr.err != nil {
260261 suffix = fmt.Sprintf(" %s(verify: %s)%s", cDim, vr.err, cReset)
262262+ } else if vr.deactivated {
263263+ suffix = fmt.Sprintf(" %s← deactivated%s", cDim, cReset)
264264+ row.deactivated++
265265+ totalDeactivated++
261266 } else if vr.exists {
262267 suffix = fmt.Sprintf(" %s← real gap%s", cRed, cReset)
263268 row.realGaps++
···272277 }
273278 }
274279280280+ // When verifying, ghost/deactivated-only diffs are considered in sync
281281+ if !inSync && *verify && row.realGaps == 0 {
282282+ inSync = true
283283+ }
284284+275285 if inSync {
276276- fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset)
286286+ notes := formatSyncNotes(row.ghosts, row.deactivated)
287287+ if notes != "" {
288288+ fmt.Printf(" %s✓ in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset)
289289+ } else {
290290+ fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset)
291291+ }
277292 row.status = "sync"
278293 } else {
279294 row.status = "diff"
···282297 }
283298284299 // Summary table
285285- printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts)
300300+ printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts, totalDeactivated)
286301}
287302288288-func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts int) {
303303+func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts, totalDeactivated int) {
289304 fmt.Printf("\n%s%s━━━ Summary ━━━%s\n\n", cBold, cCyan, cReset)
290305291306 colW := 28
···321336 }
322337 switch row.status {
323338 case "sync":
324324- fmt.Printf(" %s✓ in sync%s", cGreen, cReset)
339339+ notes := formatSyncNotes(row.ghosts, row.deactivated)
340340+ if notes != "" {
341341+ fmt.Printf(" %s✓ in sync%s %s(%s)%s", cGreen, cReset, cDim, notes, cReset)
342342+ } else {
343343+ fmt.Printf(" %s✓ in sync%s", cGreen, cReset)
344344+ }
325345 case "diff":
326346 if showVerify {
327327- fmt.Printf(" %s≠ %d missing%s %s(%d real, %d ghost)%s",
328328- cYellow, row.diffCount, cReset, cDim, row.realGaps, row.ghosts, cReset)
347347+ notes := formatSyncNotes(row.ghosts, row.deactivated)
348348+ if notes != "" {
349349+ notes = ", " + notes
350350+ }
351351+ fmt.Printf(" %s≠ %d missing%s %s(%s)%s",
352352+ cYellow, row.realGaps, cReset, cDim, fmt.Sprintf("%d real%s", row.realGaps, notes), cReset)
329353 } else {
330354 fmt.Printf(" %s≠ %d missing%s", cYellow, row.diffCount, cReset)
331355 }
···338362 // Footer
339363 fmt.Println()
340364 if totalMissing > 0 {
341341- fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset)
342342- if showVerify {
343343- fmt.Printf(" %s%d real gaps%s (record exists on PDS), %s%d ghosts%s (record deleted from PDS)\n",
344344- cRed, totalRealGaps, cReset, cDim, totalGhosts, cReset)
365365+ if showVerify && totalRealGaps == 0 {
366366+ notes := formatSyncNotes(totalGhosts, totalDeactivated)
367367+ fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset)
368368+ } else {
369369+ if showVerify {
370370+ fmt.Printf("%s%d real gaps across relays%s", cYellow, totalRealGaps, cReset)
371371+ notes := formatSyncNotes(totalGhosts, totalDeactivated)
372372+ if notes != "" {
373373+ fmt.Printf(" %s(%s)%s", cDim, notes, cReset)
374374+ }
375375+ fmt.Println()
376376+ } else {
377377+ fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset)
378378+ }
345379 }
346380 } else {
347381 fmt.Printf("%s✓ All relays fully in sync%s\n", cGreen, cReset)
348382 }
349383}
350384385385+// formatSyncNotes builds a parenthetical like "2 ghost, 1 deactivated" for sync status.
386386+// Returns empty string if both counts are zero.
387387+func formatSyncNotes(ghosts, deactivated int) string {
388388+ var parts []string
389389+ if ghosts > 0 {
390390+ parts = append(parts, fmt.Sprintf("%d ghost", ghosts))
391391+ }
392392+ if deactivated > 0 {
393393+ parts = append(parts, fmt.Sprintf("%d deactivated", deactivated))
394394+ }
395395+ return strings.Join(parts, ", ")
396396+}
397397+351398// verifyDiffs resolves each diff DID to its PDS and checks if records actually exist.
352399func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
353400 // Collect unique (DID, collection) pairs to verify
···402449403450 k := key{dc.col, dc.did}
404451405405- // Check if DID resolution failed
452452+ // Check if DID resolution failed — could mean account is deactivated/tombstoned
406453 if err, ok := pdsErrors[dc.did]; ok {
407407- mu.Lock()
408408- results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)}
409409- mu.Unlock()
454454+ errStr := err.Error()
455455+ if strings.Contains(errStr, "no PDS endpoint") ||
456456+ strings.Contains(errStr, "not found") {
457457+ mu.Lock()
458458+ results[k] = verifyResult{deactivated: true}
459459+ mu.Unlock()
460460+ } else {
461461+ mu.Lock()
462462+ results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)}
463463+ mu.Unlock()
464464+ }
410465 return
411466 }
412467···415470 records, _, err := client.ListRecordsForRepo(ctx, dc.did, dc.col, 1, "")
416471 mu.Lock()
417472 if err != nil {
418418- results[k] = verifyResult{err: err}
473473+ errStr := err.Error()
474474+ if strings.Contains(errStr, "Could not find repo") ||
475475+ strings.Contains(errStr, "RepoDeactivated") ||
476476+ strings.Contains(errStr, "RepoTakendown") ||
477477+ strings.Contains(errStr, "RepoSuspended") {
478478+ results[k] = verifyResult{deactivated: true}
479479+ } else {
480480+ results[k] = verifyResult{err: err}
481481+ }
419482 } else {
420483 results[k] = verifyResult{exists: len(records) > 0}
421484 }
+78
config-appview.example.yaml
···3737 client_short_name: ATCR
3838 # Separate domains for OCI registry API (e.g. ["buoy.cr"]). First is primary. Browser visits redirect to BaseURL.
3939 registry_domains: []
4040+ # DIDs of holds this appview manages billing for. Tier updates are pushed to these holds.
4141+ managed_holds:
4242+ - did:web:172.28.0.3%3A8080
4043# Web UI settings.
4144ui:
4245 # SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals.
···6568 - wss://jetstream1.us-east.bsky.network/subscribe
6669 # Sync existing records from PDS on startup.
6770 backfill_enabled: true
7171+ # How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup.
7272+ backfill_interval: 24h0m0s
6873 # Relay endpoints for backfill, tried in order on failure.
6974 relay_endpoints:
7075 - https://relay1.us-east.bsky.network
···8590 company_name: ""
8691 # Governing law jurisdiction for legal terms.
8792 jurisdiction: ""
9393+# Stripe billing integration (requires -tags billing build).
9494+billing:
9595+ # Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set.
9696+ stripe_secret_key: ""
9797+ # Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence).
9898+ webhook_secret: ""
9999+ # ISO 4217 currency code (e.g. "usd").
100100+ currency: usd
101101+ # Redirect URL after successful checkout. Use {base_url} placeholder.
102102+ success_url: '{base_url}/settings#storage'
103103+ # Redirect URL after cancelled checkout. Use {base_url} placeholder.
104104+ cancel_url: '{base_url}/settings#storage'
105105+ # Subscription tiers ordered by rank (lowest to highest).
106106+ tiers:
107107+ - # Tier name. Position in list determines rank (0-based).
108108+ name: free
109109+ # Short description shown on the plan card.
110110+ description: Get started with basic storage
111111+ # List of features included in this tier.
112112+ features: []
113113+ # Stripe price ID for monthly billing. Empty = free tier.
114114+ stripe_price_monthly: ""
115115+ # Stripe price ID for yearly billing.
116116+ stripe_price_yearly: ""
117117+ # Maximum webhooks for this tier (-1 = unlimited).
118118+ max_webhooks: 1
119119+ # Allow all webhook trigger types (not just first-scan).
120120+ webhook_all_triggers: false
121121+ supporter_badge: false
122122+ - # Tier name. Position in list determines rank (0-based).
123123+ name: deckhand
124124+ # Short description shown on the plan card.
125125+ description: Get started with basic storage
126126+ # List of features included in this tier.
127127+ features: []
128128+ # Stripe price ID for monthly billing. Empty = free tier.
129129+ stripe_price_monthly: ""
130130+ # Stripe price ID for yearly billing.
131131+ stripe_price_yearly: ""
132132+ # Maximum webhooks for this tier (-1 = unlimited).
133133+ max_webhooks: 1
134134+ # Allow all webhook trigger types (not just first-scan).
135135+ webhook_all_triggers: false
136136+ supporter_badge: true
137137+ - # Tier name. Position in list determines rank (0-based).
138138+ name: bosun
139139+ # Short description shown on the plan card.
140140+ description: More storage with scan-on-push
141141+ # List of features included in this tier.
142142+ features: []
143143+ # Stripe price ID for monthly billing. Empty = free tier.
144144+ stripe_price_monthly: ""
145145+ # Stripe price ID for yearly billing.
146146+ stripe_price_yearly: ""
147147+ # Maximum webhooks for this tier (-1 = unlimited).
148148+ max_webhooks: 10
149149+ # Allow all webhook trigger types (not just first-scan).
150150+ webhook_all_triggers: true
151151+ supporter_badge: true
152152+ # - # Tier name. Position in list determines rank (0-based).
153153+ # name: quartermaster
154154+ # # Short description shown on the plan card.
155155+ # description: Maximum storage for power users
156156+ # # List of features included in this tier.
157157+ # features: []
158158+ # # Stripe price ID for monthly billing. Empty = free tier.
159159+ # stripe_price_monthly: price_xxx
160160+ # # Stripe price ID for yearly billing.
161161+ # stripe_price_yearly: price_yyy
162162+ # # Maximum webhooks for this tier (-1 = unlimited).
163163+ # max_webhooks: -1
164164+ # # Allow all webhook trigger types (not just first-scan).
165165+ # webhook_all_triggers: true
+8-22
config-hold.example.yaml
···4747 test_mode: false
4848 # Request crawl from this relay on startup to make the embedded PDS discoverable.
4949 relay_endpoint: ""
5050- # Preferred appview URL for links in webhooks and Bluesky posts, e.g. "https://seamark.dev".
5151- appview_url: https://atcr.io
5050+ # DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key.
5151+ appview_did: did:web:172.28.0.2%3A5000
5252 # Read timeout for HTTP requests.
5353 read_timeout: 5m0s
5454 # Write timeout for HTTP requests.
···102102 # Quota tiers ordered by rank (lowest to highest). Position determines rank.
103103 tiers:
104104 - # Tier name used as the key for crew assignments.
105105+ name: free
106106+ # Storage quota limit (e.g. "5GB", "50GB", "1TB").
107107+ quota: 5GB
108108+ # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
109109+ scan_on_push: false
110110+ - # Tier name used as the key for crew assignments.
105111 name: deckhand
106112 # Storage quota limit (e.g. "5GB", "50GB", "1TB").
107113 quota: 5GB
108114 # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
109115 scan_on_push: false
110110- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
111111- max_webhooks: 1
112112- # Allow all webhook trigger types. Free tiers only get scan:first.
113113- webhook_all_triggers: false
114114- # Show supporter badge on user profiles for members at this tier.
115115- supporter_badge: false
116116 - # Tier name used as the key for crew assignments.
117117 name: bosun
118118 # Storage quota limit (e.g. "5GB", "50GB", "1TB").
119119 quota: 50GB
120120 # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
121121 scan_on_push: true
122122- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
123123- max_webhooks: 5
124124- # Allow all webhook trigger types. Free tiers only get scan:first.
125125- webhook_all_triggers: true
126126- # Show supporter badge on user profiles for members at this tier.
127127- supporter_badge: true
128122 - # Tier name used as the key for crew assignments.
129123 name: quartermaster
130124 # Storage quota limit (e.g. "5GB", "50GB", "1TB").
131125 quota: 100GB
132126 # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
133127 scan_on_push: true
134134- # Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
135135- max_webhooks: -1
136136- # Allow all webhook trigger types. Free tiers only get scan:first.
137137- webhook_all_triggers: true
138138- # Show supporter badge on user profiles for members at this tier.
139139- supporter_badge: true
140128 # Default tier assignment for new crew members.
141129 defaults:
142130 # Tier assigned to new crew members who don't have an explicit tier.
143131 new_crew_tier: deckhand
144144- # Show supporter badge on the hold owner's profile.
145145- owner_badge: true
146132# Vulnerability scanner settings. Empty disables scanning.
147133scanner:
148134 # Shared secret for scanner WebSocket auth. Empty disables scanning.
···2020 ATCR_LOG_LEVEL: debug
2121 LOG_SHIPPER_BACKEND: victoria
2222 LOG_SHIPPER_URL: http://172.28.0.10:9428
2323+ # Stripe billing (only used with -tags billing)
2424+ STRIPE_SECRET_KEY: sk_test_
2525+ STRIPE_PUBLISHABLE_KEY: pk_test_
2626+ STRIPE_WEBHOOK_SECRET: whsec_
2327 # Limit local Docker logs - real logs go to Victoria Logs
2428 # Local logs just for live tailing (docker logs -f)
2529 logging:
···5761 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
5862 HOLD_REGISTRATION_ALLOW_ALL_CREW: true
5963 HOLD_SERVER_TEST_MODE: true
6060- # Stripe billing (only used with -tags billing)
6161- STRIPE_SECRET_KEY: sk_test_
6262- STRIPE_PUBLISHABLE_KEY: pk_test_
6363- STRIPE_WEBHOOK_SECRET: whsec_
6464 HOLD_LOG_LEVEL: debug
6565 LOG_SHIPPER_BACKEND: victoria
6666 LOG_SHIPPER_URL: http://172.28.0.10:9428
+348
docs/BILLING_REFACTOR.md
···11+# Billing & Webhooks Refactor: Move to AppView
22+33+## Motivation
44+55+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:
66+77+1. **Multi-hold confusion**: A user on 3 holds could have 3 separate Stripe subscriptions with no unified view
88+2. **Orphaned subscriptions**: Users can end up paying for holds they no longer use after switching their active hold
99+3. **Complex UI**: The settings page needs to surface billing per-hold, with separate "Manage Billing" links for each
1010+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
1111+1212+The proposed model is **per-appview**: a single Stripe integration on the appview, one subscription per user, covering all holds that appview manages.
1313+1414+## Current Architecture
1515+1616+```
1717+User ──Settings UI──→ AppView ──XRPC──→ Hold ──Stripe API──→ Stripe
1818+ ↑
1919+ Stripe Webhooks
2020+```
2121+2222+### What lives where today
2323+2424+| Component | Location | Notes |
2525+|-----------|----------|-------|
2626+| Stripe customer management | Hold (`pkg/hold/billing/`) | Build tag: `-tags billing` |
2727+| Stripe checkout/portal | Hold XRPC endpoints | Authenticated via service token |
2828+| Stripe webhook receiver | Hold (`stripeWebhook` endpoint) | Updates crew tier on subscription change |
2929+| Tier definitions + pricing | Hold config (`quotas.yaml`, `billing` section) | Captain configures |
3030+| Quota enforcement | Hold (`pkg/hold/quota/`) | Checks tier limit on push |
3131+| Storage quota calculation | Hold PDS layer records | Deduped per-user |
3232+| Subscription UI | AppView handlers | Proxies all calls to hold |
3333+| Webhook management (scan) | Hold PDS + SQLite | URL/secret in SQLite, metadata in PDS record |
3434+| Webhook dispatch | Hold (`scan_broadcaster.go`) | Sends on scan completion |
3535+| Sailor webhook record | User's PDS | Links to hold's private webhook record |
3636+3737+## Proposed Architecture
3838+3939+```
4040+User ──Settings UI──→ AppView ──Stripe API──→ Stripe
4141+ │ ↑
4242+ │ Stripe Webhooks
4343+ │
4444+ ├──XRPC──→ Hold A (quota enforcement, scan results)
4545+ ├──XRPC──→ Hold B
4646+ └──XRPC──→ Hold C
4747+4848+ AppView signs attestation
4949+ │
5050+ └──→ Hold stores in PDS (trust anchor)
5151+```
5252+5353+### What moves to AppView
5454+5555+| Component | From | To | Notes |
5656+|-----------|------|----|-------|
5757+| Stripe customer management | Hold | AppView | One customer per user, not per hold |
5858+| Stripe checkout/portal | Hold | AppView | Single subscription covers all holds |
5959+| Stripe webhook receiver | Hold | AppView | AppView updates tier across all holds |
6060+| Tier definitions + pricing | Hold config | AppView config | AppView defines billing tiers |
6161+| Scan webhooks (storage + dispatch) | Hold | AppView | AppView has user context, scan data comes via Jetstream/XRPC |
6262+6363+### What stays on the hold
6464+6565+| Component | Notes |
6666+|-----------|-------|
6767+| Quota enforcement | Hold still checks tier limit on push |
6868+| Storage quota calculation | Layer records stay in hold PDS |
6969+| Tier definitions (quota only) | Hold defines storage limits per tier, no pricing |
7070+| Scan execution + results | Scanner still talks to hold, results stored in hold PDS |
7171+| Crew tier field | Source of truth for enforcement, updated by appview |
7272+7373+## Billing Model
7474+7575+### One subscription, all holds
7676+7777+A user pays the appview once. Their subscription tier applies across every hold the appview manages.
7878+7979+```
8080+AppView billing tiers: [Free] [Tier 1] [Tier 2]
8181+ │ │ │
8282+ ▼ ▼ ▼
8383+Hold A tiers (3GB/10GB/50GB): deckhand bosun quartermaster
8484+Hold B tiers (5GB/20GB/∞): deckhand bosun quartermaster
8585+```
8686+8787+### Tier pairing
8888+8989+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.
9090+9191+- AppView doesn't need to know tier names — just "slot 1, slot 2, slot 3"
9292+- Each hold independently decides what storage limit each tier gets
9393+- The settings UI shows the range: "5-10 GB depending on region" or "minimum 5 GB"
9494+9595+### Hold captains who want to charge
9696+9797+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.
9898+9999+## AppView-Hold Trust Model
100100+101101+### Problem
102102+103103+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.
104104+105105+### Attestation handshake
106106+107107+1. **Hold config** already has `server.appview_url` (preferred appview)
108108+2. **AppView config** gains a `managed_holds` list (DIDs of holds it manages)
109109+3. On first connection, the appview signs an attestation with its private key:
110110+ ```json
111111+ {
112112+ "$type": "io.atcr.appview.attestation",
113113+ "appviewDid": "did:web:atcr.io",
114114+ "holdDid": "did:web:hold01.atcr.io",
115115+ "issuedAt": "2026-02-23T...",
116116+ "signature": "<signed with appview's P-256 key>"
117117+ }
118118+ ```
119119+4. The hold stores this attestation in its embedded PDS
120120+5. On subsequent requests, the hold can challenge the appview: present the attestation, appview proves it holds the matching private key
121121+6. If the appview's domain changes, the attestation (tied to DID, not URL) remains valid
122122+123123+### Trust verification flow
124124+125125+```
126126+AppView boots → checks managed_holds list
127127+ → for each hold:
128128+ → calls hold's describeServer endpoint to verify DID
129129+ → signs attestation { appviewDid, holdDid, issuedAt }
130130+ → sends to hold via XRPC
131131+ → hold stores in PDS as io.atcr.hold.appview record
132132+133133+Hold receives tier update from appview:
134134+ → checks: does this request come from my preferred appview?
135135+ → verifies: signature on stored attestation matches appview's current key
136136+ → if valid: updates crew tier
137137+ → if invalid: rejects, logs warning
138138+```
139139+140140+### Key material
141141+142142+- **AppView**: P-256 key (already exists at `/var/lib/atcr/oauth/client.key`, used for OAuth)
143143+- **Hold**: K-256 key (PDS signing key)
144144+- Attestation is signed by appview's P-256 key, verifiable by anyone with the appview's public key (available via DID document)
145145+146146+## Webhooks: Move to AppView
147147+148148+### Why move
149149+150150+Scan webhooks currently live on the hold, but:
151151+- The webhook payload needs user handles, repository names, tags — all resolved by the appview
152152+- The hold only has DIDs and digests
153153+- The appview already processes scan records via Jetstream (backfill + live)
154154+- Webhook secrets shouldn't need to live on every hold the user pushes to
155155+156156+### New flow
157157+158158+```
159159+Scanner completes scan
160160+ → Hold stores scan record in PDS
161161+ → Jetstream delivers scan record to AppView
162162+ → AppView resolves user handle, repo name, tags
163163+ → AppView dispatches webhooks with full context
164164+```
165165+166166+### What changes
167167+168168+| Aspect | Current (hold) | Proposed (appview) |
169169+|--------|---------------|-------------------|
170170+| Webhook storage | Hold SQLite + PDS record | AppView DB + user's PDS record |
171171+| Webhook secrets | Hold SQLite (`webhook_secrets` table) | AppView DB |
172172+| Dispatch trigger | `scan_broadcaster.go` on scan completion | Jetstream processor on `io.atcr.hold.scan` record |
173173+| Payload enrichment | Hold fetches handle from appview metadata | AppView has full context natively |
174174+| Discord/Slack formatting | Hold (`webhooks.go`) | AppView (same code, moved) |
175175+| Tier-based limits | Hold quota manager | AppView billing tier |
176176+| XRPC endpoints | Hold (`listWebhooks`, `addWebhook`, etc.) | AppView API endpoints (already exist as proxies) |
177177+178178+### Webhook record changes
179179+180180+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.
181181+182182+The `io.atcr.hold.webhook` record in the hold's PDS is no longer needed. Webhooks are appview-scoped, not hold-scoped.
183183+184184+### Migration path
185185+186186+1. AppView gains webhook storage in its own DB (new table)
187187+2. AppView gains webhook dispatch in its Jetstream processor
188188+3. Hold's webhook endpoints deprecated (return 410 Gone after transition period)
189189+4. Existing hold webhook records migrated via one-time script reading from hold XRPC + user PDS
190190+191191+## Config Changes
192192+193193+### AppView config additions
194194+195195+```yaml
196196+server:
197197+ # Existing
198198+ default_hold_did: "did:web:hold01.atcr.io"
199199+200200+ # New
201201+ managed_holds:
202202+ - "did:web:hold01.atcr.io"
203203+ - "did:plc:abc123..."
204204+205205+# New section
206206+billing:
207207+ enabled: true
208208+ currency: usd
209209+ success_url: "{base_url}/settings#storage"
210210+ cancel_url: "{base_url}/settings#storage"
211211+ tiers:
212212+ - name: "Free"
213213+ # No stripe_price = free tier
214214+ - name: "Standard"
215215+ stripe_price_monthly: price_xxx
216216+ stripe_price_yearly: price_yyy
217217+ - name: "Pro"
218218+ stripe_price_monthly: price_xxx
219219+ stripe_price_yearly: price_yyy
220220+```
221221+222222+### AppView environment additions
223223+224224+```bash
225225+STRIPE_SECRET_KEY=sk_live_xxx
226226+STRIPE_WEBHOOK_SECRET=whsec_xxx
227227+```
228228+229229+### Hold config changes
230230+231231+```yaml
232232+# Removed
233233+billing:
234234+ # entire section removed from hold config
235235+236236+# Stays (quota enforcement only)
237237+quota:
238238+ tiers:
239239+ - name: deckhand
240240+ quota: 5GB
241241+ - name: bosun
242242+ quota: 50GB
243243+ - name: quartermaster
244244+ quota: 100GB
245245+ defaults:
246246+ new_crew_tier: deckhand
247247+```
248248+249249+The hold no longer has Stripe config. It just defines storage limits per tier and enforces them.
250250+251251+## AppView DB Schema Additions
252252+253253+```sql
254254+-- Webhook configurations (moved from hold SQLite)
255255+CREATE TABLE webhooks (
256256+ id INTEGER PRIMARY KEY AUTOINCREMENT,
257257+ user_did TEXT NOT NULL,
258258+ url TEXT NOT NULL,
259259+ secret_hash TEXT, -- bcrypt hash of HMAC secret
260260+ triggers INTEGER NOT NULL DEFAULT 1, -- bitmask: first=1, all=2, changed=4
261261+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
262262+ UNIQUE(user_did, url)
263263+);
264264+265265+-- Billing: track which holds have been attested
266266+CREATE TABLE hold_attestations (
267267+ hold_did TEXT PRIMARY KEY,
268268+ attestation_cid TEXT NOT NULL, -- CID of attestation record in hold's PDS
269269+ issued_at DATETIME NOT NULL,
270270+ verified_at DATETIME
271271+);
272272+```
273273+274274+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.
275275+276276+## Implementation Phases
277277+278278+### Phase 1: Trust foundation
279279+- Add `managed_holds` to appview config
280280+- Implement attestation signing (appview) and storage (hold)
281281+- Add attestation verification to hold's tier-update endpoint
282282+- New XRPC endpoint on hold: `io.atcr.hold.updateCrewTier` (appview-authenticated)
283283+284284+### Phase 2: Billing migration
285285+- Move Stripe integration from hold to appview (reuse `pkg/hold/billing/` code)
286286+- AppView billing uses `-tags billing` build tag (same pattern)
287287+- Implement tier pairing: appview billing slots mapped to hold tier lists
288288+- New appview endpoints: checkout, portal, stripe webhook receiver
289289+- Settings UI: single subscription section (not per-hold)
290290+291291+### Phase 3: Webhook migration ✅
292292+- Add webhook + scans tables to appview DB
293293+- Implement webhook dispatch in appview's Jetstream processor
294294+- Move Discord/Slack formatting code to `pkg/appview/webhooks/`
295295+- Deprecate hold webhook XRPC endpoints (X-Deprecated header)
296296+- Webhooks now user-scoped (global across all holds) in appview DB
297297+- Scan records cached from Jetstream for change detection
298298+299299+### Phase 4: Cleanup ✅
300300+- Removed hold webhook XRPC endpoints, dispatch code, and `webhooks.go`
301301+- Removed `io.atcr.hold.webhook` and `io.atcr.sailor.webhook` record types + lexicons
302302+- Removed `webhook_secrets` SQLite schema from scan_broadcaster
303303+- Removed `MaxWebhooks`/`WebhookAllTriggers` from hold quota config
304304+- Removed sailor webhook from OAuth scopes
305305+306306+## Settings UI Impact
307307+308308+The storage tab simplifies significantly:
309309+310310+```
311311+┌──────────────────────────────────────────────────────┐
312312+│ Active Hold: [▼ hold01.atcr.io (Crew) ] │
313313+└──────────────────────────────────────────────────────┘
314314+315315+┌──────────────────────────────────────────────────────┐
316316+│ Subscription: Standard ($5/mo) [Manage Billing] │
317317+│ Storage: 3-5 GB depending on region │
318318+└──────────────────────────────────────────────────────┘
319319+320320+┌──────────────────────────────────────────────────────┐
321321+│ ★ hold01.atcr.io [Active] [Crew] [Online] │
322322+│ Tier: bosun · 281.5 MB / 5.0 GB (5%) │
323323+│ ▸ Webhooks (2 configured) │
324324+└──────────────────────────────────────────────────────┘
325325+326326+┌──────────────────────────────────────────────────────┐
327327+│ Other Holds Role Status Storage │
328328+│ hold02.atcr.io Crew ● 230 MB / 3 GB │
329329+│ hold03.atcr.io Owner ● No data │
330330+└──────────────────────────────────────────────────────┘
331331+```
332332+333333+Key changes:
334334+- **One subscription section** at the top (not per-hold)
335335+- **Webhooks section** under active hold card (managed by appview now)
336336+- **No "Paid" badge per hold** — subscription is global
337337+- **Storage range** shown on subscription card ("3-5 GB depending on region")
338338+- **Per-hold quota** still shown (each hold enforces its own limit for the user's tier)
339339+340340+## Open Questions
341341+342342+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`.
343343+344344+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.
345345+346346+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.
347347+348348+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
···2121| `/xrpc/com.atproto.identity.resolveHandle` | GET | Resolve handle to DID |
2222| `/xrpc/app.bsky.actor.getProfile` | GET | Get actor profile |
2323| `/xrpc/app.bsky.actor.getProfiles` | GET | Get multiple profiles |
2424+| `/xrpc/io.atcr.hold.listTiers` | GET | List hold's available tiers with quotas and features |
2425| `/.well-known/did.json` | GET | DID document |
2526| `/.well-known/atproto-did` | GET | DID for handle resolution |
2627···4344|----------|--------|-------------|
4445| `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership |
4546| `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) |
4646-| `/xrpc/io.atcr.hold.listWebhooks` | GET | List user's webhook configurations |
4747-| `/xrpc/io.atcr.hold.addWebhook` | POST | Add a webhook (tier-gated) |
4848-| `/xrpc/io.atcr.hold.deleteWebhook` | POST | Delete a webhook |
4949-| `/xrpc/io.atcr.hold.testWebhook` | POST | Send test payload to a webhook |
4747+### Appview Token Required
4848+4949+| Endpoint | Method | Description |
5050+|----------|--------|-------------|
5151+| `/xrpc/io.atcr.hold.updateCrewTier` | POST | Update a crew member's tier (appview-only) |
50525153---
5254···7880| `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership |
7981| `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export |
8082| `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info |
8181-| `/xrpc/io.atcr.hold.listWebhooks` | GET | auth | List user's webhook configs |
8282-| `/xrpc/io.atcr.hold.addWebhook` | POST | auth | Add webhook (tier-gated) |
8383-| `/xrpc/io.atcr.hold.deleteWebhook` | POST | auth | Delete a webhook |
8484-| `/xrpc/io.atcr.hold.testWebhook` | POST | auth | Send test payload to webhook |
8383+| `/xrpc/io.atcr.hold.listTiers` | GET | none | List hold's available tiers with quotas and features (scanOnPush) |
8484+| `/xrpc/io.atcr.hold.updateCrewTier` | POST | appview token | Update crew member's tier |
85858686---
8787
-59
lexicons/io/atcr/hold/addWebhook.json
···11-{
22- "lexicon": 1,
33- "id": "io.atcr.hold.addWebhook",
44- "defs": {
55- "main": {
66- "type": "procedure",
77- "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.",
88- "input": {
99- "encoding": "application/json",
1010- "schema": {
1111- "type": "object",
1212- "required": ["url", "triggers"],
1313- "properties": {
1414- "url": {
1515- "type": "string",
1616- "format": "uri",
1717- "maxLength": 2048,
1818- "description": "HTTPS URL to receive webhook payloads"
1919- },
2020- "secret": {
2121- "type": "string",
2222- "description": "Optional HMAC-SHA256 signing secret. When set, payloads include an X-Webhook-Signature-256 header.",
2323- "maxLength": 256
2424- },
2525- "triggers": {
2626- "type": "integer",
2727- "minimum": 1,
2828- "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
2929- }
3030- }
3131- }
3232- },
3333- "output": {
3434- "encoding": "application/json",
3535- "schema": {
3636- "type": "object",
3737- "required": ["rkey", "cid"],
3838- "properties": {
3939- "rkey": {
4040- "type": "string",
4141- "maxLength": 64,
4242- "description": "Record key of the created io.atcr.hold.webhook record"
4343- },
4444- "cid": {
4545- "type": "string",
4646- "maxLength": 128,
4747- "description": "CID of the created record (used as privateCid in the sailor webhook record)"
4848- }
4949- }
5050- }
5151- },
5252- "errors": [
5353- { "name": "InvalidUrl", "description": "URL is not a valid HTTPS endpoint" },
5454- { "name": "WebhookLimitReached", "description": "User has reached the maximum number of webhooks for their tier" },
5555- { "name": "TriggerNotAllowed", "description": "Trigger types beyond scan:first require a paid tier" }
5656- ]
5757- }
5858- }
5959-}
-8
lexicons/io/atcr/hold/captain.json
···4141 "type": "string",
4242 "format": "did",
4343 "description": "DID of successor hold for migration redirect"
4444- },
4545- "supporterBadgeTiers": {
4646- "type": "array",
4747- "description": "Tier names that earn a supporter badge on user profiles",
4848- "items": {
4949- "type": "string",
5050- "maxLength": 64
5151- }
5244 }
5345 }
5446 }
-41
lexicons/io/atcr/hold/deleteWebhook.json
···11-{
22- "lexicon": 1,
33- "id": "io.atcr.hold.deleteWebhook",
44- "defs": {
55- "main": {
66- "type": "procedure",
77- "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.",
88- "input": {
99- "encoding": "application/json",
1010- "schema": {
1111- "type": "object",
1212- "required": ["rkey"],
1313- "properties": {
1414- "rkey": {
1515- "type": "string",
1616- "maxLength": 64,
1717- "description": "Record key of the io.atcr.hold.webhook record to delete"
1818- }
1919- }
2020- }
2121- },
2222- "output": {
2323- "encoding": "application/json",
2424- "schema": {
2525- "type": "object",
2626- "required": ["success"],
2727- "properties": {
2828- "success": {
2929- "type": "boolean",
3030- "description": "Whether the webhook was successfully deleted"
3131- }
3232- }
3333- }
3434- },
3535- "errors": [
3636- { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" },
3737- { "name": "Unauthorized", "description": "Webhook belongs to a different user" }
3838- ]
3939- }
4040- }
4141-}
···11-{
22- "lexicon": 1,
33- "id": "io.atcr.hold.testWebhook",
44- "defs": {
55- "main": {
66- "type": "procedure",
77- "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.",
88- "input": {
99- "encoding": "application/json",
1010- "schema": {
1111- "type": "object",
1212- "required": ["rkey"],
1313- "properties": {
1414- "rkey": {
1515- "type": "string",
1616- "maxLength": 64,
1717- "description": "Record key of the io.atcr.hold.webhook to test"
1818- }
1919- }
2020- }
2121- },
2222- "output": {
2323- "encoding": "application/json",
2424- "schema": {
2525- "type": "object",
2626- "required": ["success"],
2727- "properties": {
2828- "success": {
2929- "type": "boolean",
3030- "description": "Whether the test delivery received a 2xx response"
3131- }
3232- }
3333- }
3434- },
3535- "errors": [
3636- { "name": "WebhookNotFound", "description": "No webhook found with the given rkey" },
3737- { "name": "Unauthorized", "description": "Webhook belongs to a different user" }
3838- ]
3939- }
4040- }
4141-}
+53
lexicons/io/atcr/hold/updateCrewTier.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.updateCrewTier",
44+ "defs": {
55+ "main": {
66+ "type": "procedure",
77+ "description": "Update a crew member's tier. Only accepts requests from the trusted appview.",
88+ "input": {
99+ "encoding": "application/json",
1010+ "schema": {
1111+ "type": "object",
1212+ "required": ["userDid", "tierRank"],
1313+ "properties": {
1414+ "userDid": {
1515+ "type": "string",
1616+ "format": "did",
1717+ "description": "DID of the crew member whose tier is being updated."
1818+ },
1919+ "tierRank": {
2020+ "type": "integer",
2121+ "minimum": 0,
2222+ "description": "Tier rank index (0-based, maps to hold tier list by position)."
2323+ }
2424+ }
2525+ }
2626+ },
2727+ "output": {
2828+ "encoding": "application/json",
2929+ "schema": {
3030+ "type": "object",
3131+ "required": ["tierName"],
3232+ "properties": {
3333+ "tierName": {
3434+ "type": "string",
3535+ "maxLength": 64,
3636+ "description": "Resolved tier name on this hold."
3737+ }
3838+ }
3939+ }
4040+ },
4141+ "errors": [
4242+ {
4343+ "name": "AuthRequired",
4444+ "description": "Valid appview token required."
4545+ },
4646+ {
4747+ "name": "UserNotFound",
4848+ "description": "User is not a crew member on this hold."
4949+ }
5050+ ]
5151+ }
5252+ }
5353+}
-32
lexicons/io/atcr/hold/webhook.json
···11-{
22- "lexicon": 1,
33- "id": "io.atcr.hold.webhook",
44- "defs": {
55- "main": {
66- "type": "record",
77- "key": "any",
88- "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.",
99- "record": {
1010- "type": "object",
1111- "required": ["userDid", "triggers", "createdAt"],
1212- "properties": {
1313- "userDid": {
1414- "type": "string",
1515- "format": "did",
1616- "description": "DID of the webhook owner"
1717- },
1818- "triggers": {
1919- "type": "integer",
2020- "minimum": 0,
2121- "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
2222- },
2323- "createdAt": {
2424- "type": "string",
2525- "format": "datetime",
2626- "description": "RFC3339 timestamp of when the webhook was created"
2727- }
2828- }
2929- }
3030- }
3131- }
3232-}
-42
lexicons/io/atcr/sailor/webhook.json
···11-{
22- "lexicon": 1,
33- "id": "io.atcr.sailor.webhook",
44- "defs": {
55- "main": {
66- "type": "record",
77- "key": "tid",
88- "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.",
99- "record": {
1010- "type": "object",
1111- "required": ["holdDid", "triggers", "privateCid", "createdAt"],
1212- "properties": {
1313- "holdDid": {
1414- "type": "string",
1515- "format": "did",
1616- "description": "DID of the hold where the webhook is configured"
1717- },
1818- "triggers": {
1919- "type": "integer",
2020- "minimum": 0,
2121- "description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
2222- },
2323- "privateCid": {
2424- "type": "string",
2525- "maxLength": 128,
2626- "description": "CID of the corresponding io.atcr.hold.webhook record on the hold"
2727- },
2828- "createdAt": {
2929- "type": "string",
3030- "format": "datetime",
3131- "description": "RFC3339 timestamp of when the webhook was created"
3232- },
3333- "updatedAt": {
3434- "type": "string",
3535- "format": "datetime",
3636- "description": "RFC3339 timestamp of when the webhook was last updated"
3737- }
3838- }
3939- }
4040- }
4141- }
4242-}
+3-3
lexicons/io/atcr/tag.json
···88 "key": "any",
99 "record": {
1010 "type": "object",
1111- "required": ["repository", "tag", "createdAt"],
1111+ "required": ["repository", "tag"],
1212 "properties": {
1313 "repository": {
1414 "type": "string",
···3030 "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.",
3131 "maxLength": 128
3232 },
3333- "createdAt": {
3333+ "updatedAt": {
3434 "type": "string",
3535 "format": "datetime",
3636- "description": "Tag creation timestamp"
3636+ "description": "Timestamp of last tag update"
3737 }
3838 }
3939 }
+24-1
pkg/appview/config.go
···1616 "github.com/distribution/distribution/v3/configuration"
1717 "github.com/spf13/viper"
18181919+ "atcr.io/pkg/billing"
1920 "atcr.io/pkg/config"
2021)
2122···3132 Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."`
3233 CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."`
3334 Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
3535+ Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
3436 Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
3537}
3638···59616062 // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display).
6163 RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."`
6464+6565+ // DIDs of holds this appview manages billing for.
6666+ ManagedHolds []string `yaml:"managed_holds" comment:"DIDs of holds this appview manages billing for. Tier updates are pushed to these holds."`
6267}
63686469// UIConfig defines web UI settings
···97102 // Sync existing records from PDS on startup.
98103 BackfillEnabled bool `yaml:"backfill_enabled" comment:"Sync existing records from PDS on startup."`
99104105105+ // How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup.
106106+ 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."`
107107+100108 // Relay endpoints for backfill, tried in order on failure.
101109 RelayEndpoints []string `yaml:"relay_endpoints" comment:"Relay endpoints for backfill, tried in order on failure."`
102110}
···146154 v.SetDefault("server.client_short_name", "ATCR")
147155 v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key")
148156 v.SetDefault("server.registry_domains", []string{})
157157+ v.SetDefault("server.managed_holds", []string{})
149158150159 // UI defaults
151160 v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db")
···166175 "wss://jetstream1.us-east.bsky.network/subscribe",
167176 })
168177 v.SetDefault("jetstream.backfill_enabled", true)
178178+ v.SetDefault("jetstream.backfill_interval", "24h")
169179 v.SetDefault("jetstream.relay_endpoints", []string{
170180 "https://relay1.us-east.bsky.network",
171181 "https://relay1.us-west.bsky.network",
···199209200210// ExampleYAML returns a fully-commented YAML configuration with default values.
201211func ExampleYAML() ([]byte, error) {
202202- return config.MarshalCommentedYAML("ATCR AppView Configuration", DefaultConfig())
212212+ cfg := DefaultConfig()
213213+214214+ // Populate example billing tiers so operators see the structure
215215+ cfg.Billing.Currency = "usd"
216216+ cfg.Billing.SuccessURL = "{base_url}/settings#storage"
217217+ cfg.Billing.CancelURL = "{base_url}/settings#storage"
218218+ cfg.Billing.OwnerBadge = true
219219+ cfg.Billing.Tiers = []billing.BillingTierConfig{
220220+ {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1},
221221+ {Name: "bosun", Description: "More storage with scan-on-push", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true},
222222+ {Name: "quartermaster", Description: "Maximum storage for power users", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true},
223223+ }
224224+225225+ return config.MarshalCommentedYAML("ATCR AppView Configuration", cfg)
203226}
204227205228// LoadConfig builds a complete configuration using Viper layered loading:
+30-6
pkg/appview/db/annotations.go
···11package db
2233-import "time"
33+import (
44+ "strings"
55+ "time"
66+)
4758// GetRepositoryAnnotations retrieves all annotations for a repository
69func GetRepositoryAnnotations(db DBTX, did, repository string) (map[string]string, error) {
···2629 return annotations, rows.Err()
2730}
28312929-// UpsertRepositoryAnnotations replaces all annotations for a repository
3232+// UpsertRepositoryAnnotations upserts annotations for a repository.
3333+// Stale keys not present in the new map are deleted.
3434+// Unchanged values are skipped to avoid unnecessary writes.
3035// Only called when manifest has at least one non-empty annotation.
3136// Atomicity is provided by the caller's transaction when used during backfill.
3237func UpsertRepositoryAnnotations(db DBTX, did, repository string, annotations map[string]string) error {
3333- // Delete existing annotations
3838+ // Delete keys that are no longer in the annotation set
3939+ if len(annotations) == 0 {
4040+ _, err := db.Exec(`
4141+ DELETE FROM repository_annotations
4242+ WHERE did = ? AND repository = ?
4343+ `, did, repository)
4444+ return err
4545+ }
4646+4747+ // Build placeholders for the NOT IN clause
4848+ placeholders := make([]string, 0, len(annotations))
4949+ args := []any{did, repository}
5050+ for key := range annotations {
5151+ placeholders = append(placeholders, "?")
5252+ args = append(args, key)
5353+ }
3454 _, err := db.Exec(`
3555 DELETE FROM repository_annotations
3636- WHERE did = ? AND repository = ?
3737- `, did, repository)
5656+ WHERE did = ? AND repository = ? AND key NOT IN (`+strings.Join(placeholders, ",")+`)
5757+ `, args...)
3858 if err != nil {
3959 return err
4060 }
41614242- // Insert new annotations
6262+ // Upsert each annotation, only writing when value changed
4363 stmt, err := db.Prepare(`
4464 INSERT INTO repository_annotations (did, repository, key, value, updated_at)
4565 VALUES (?, ?, ?, ?, ?)
6666+ ON CONFLICT(did, repository, key) DO UPDATE SET
6767+ value = excluded.value,
6868+ updated_at = excluded.updated_at
6969+ WHERE excluded.value != repository_annotations.value
4670 `)
4771 if err != nil {
4872 return err
+23-89
pkg/appview/db/hold_store.go
···2233import (
44 "database/sql"
55- "encoding/json"
65 "fmt"
76 "strings"
87 "time"
···20192120// HoldCaptainRecord represents a cached captain record from a hold's PDS
2221type HoldCaptainRecord struct {
2323- HoldDID string `json:"-"` // Set manually, not from JSON
2424- OwnerDID string `json:"owner"`
2525- Public bool `json:"public"`
2626- AllowAllCrew bool `json:"allowAllCrew"`
2727- DeployedAt string `json:"deployedAt"`
2828- Region string `json:"region"`
2929- Successor string `json:"successor"` // DID of successor hold (migration redirect)
3030- SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
3131- UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
2222+ HoldDID string `json:"-"` // Set manually, not from JSON
2323+ OwnerDID string `json:"owner"`
2424+ Public bool `json:"public"`
2525+ AllowAllCrew bool `json:"allowAllCrew"`
2626+ DeployedAt string `json:"deployedAt"`
2727+ Region string `json:"region"`
2828+ Successor string `json:"successor"` // DID of successor hold (migration redirect)
2929+ UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
3230}
33313432// GetCaptainRecord retrieves a captain record from the cache
···3634func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
3735 query := `
3836 SELECT hold_did, owner_did, public, allow_all_crew,
3939- deployed_at, region, successor, supporter_badge_tiers, updated_at
3737+ deployed_at, region, successor, updated_at
4038 FROM hold_captain_records
4139 WHERE hold_did = ?
4240 `
43414442 var record HoldCaptainRecord
4545- var deployedAt, region, successor, supporterBadgeTiers sql.NullString
4343+ var deployedAt, region, successor sql.NullString
46444745 err := db.QueryRow(query, holdDID).Scan(
4846 &record.HoldDID,
···5250 &deployedAt,
5351 ®ion,
5452 &successor,
5555- &supporterBadgeTiers,
5653 &record.UpdatedAt,
5754 )
5855···7471 if successor.Valid {
7572 record.Successor = successor.String
7673 }
7777- if supporterBadgeTiers.Valid {
7878- record.SupporterBadgeTiers = supporterBadgeTiers.String
7979- }
80748175 return &record, nil
8276}
···8680 query := `
8781 INSERT INTO hold_captain_records (
8882 hold_did, owner_did, public, allow_all_crew,
8989- deployed_at, region, successor, supporter_badge_tiers, updated_at
9090- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
8383+ deployed_at, region, successor, updated_at
8484+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
9185 ON CONFLICT(hold_did) DO UPDATE SET
9286 owner_did = excluded.owner_did,
9387 public = excluded.public,
···9589 deployed_at = excluded.deployed_at,
9690 region = excluded.region,
9791 successor = excluded.successor,
9898- supporter_badge_tiers = excluded.supporter_badge_tiers,
9992 updated_at = excluded.updated_at
9393+ WHERE excluded.owner_did != hold_captain_records.owner_did
9494+ OR excluded.public != hold_captain_records.public
9595+ OR excluded.allow_all_crew != hold_captain_records.allow_all_crew
9696+ OR excluded.deployed_at IS NOT hold_captain_records.deployed_at
9797+ OR excluded.region IS NOT hold_captain_records.region
9898+ OR excluded.successor IS NOT hold_captain_records.successor
10099 `
101100102101 _, err := db.Exec(query,
···107106 nullString(record.DeployedAt),
108107 nullString(record.Region),
109108 nullString(record.Successor),
110110- nullString(record.SupporterBadgeTiers),
111109 record.UpdatedAt,
112110 )
113111···118116 return nil
119117}
120118121121-// HasSupporterBadge checks if a given tier is in the hold's supporter badge tiers list.
122122-func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool {
123123- if r.SupporterBadgeTiers == "" || tier == "" {
124124- return false
125125- }
126126- var tiers []string
127127- if err := json.Unmarshal([]byte(r.SupporterBadgeTiers), &tiers); err != nil {
128128- return false
129129- }
130130- for _, t := range tiers {
131131- if t == tier {
132132- return true
133133- }
134134- }
135135- return false
136136-}
137137-138138-// normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators.
139139-// This is a local copy to avoid importing atproto (prevents circular dependencies).
140140-func normalizeDidWeb(did string) string {
141141- if !strings.HasPrefix(did, "did:web:") {
142142- return did
143143- }
144144- host := strings.TrimPrefix(did, "did:web:")
145145- if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
146146- host = strings.Replace(host, ":", "%3A", 1)
147147- }
148148- return "did:web:" + host
149149-}
150150-151151-// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold.
152152-// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible,
153153-// or the user isn't a member of the hold.
154154-func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string {
155155- if holdDID == "" || userDID == "" {
156156- return ""
157157- }
158158-159159- // Normalize did:web encoding for consistent comparison
160160- holdDID = normalizeDidWeb(holdDID)
161161-162162- captain, err := GetCaptainRecord(dbConn, holdDID)
163163- if err != nil || captain == nil || captain.SupporterBadgeTiers == "" {
164164- return ""
165165- }
166166-167167- // If user is the owner and "owner" badge is enabled, show it
168168- if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") {
169169- return "owner"
170170- }
171171-172172- // Look up crew membership for this user on this hold
173173- memberships, err := GetCrewMemberships(dbConn, userDID)
174174- if err != nil {
175175- return ""
176176- }
177177-178178- for _, m := range memberships {
179179- if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" {
180180- if captain.HasSupporterBadge(m.Tier) {
181181- return m.Tier
182182- }
183183- return ""
184184- }
185185- }
186186-187187- return ""
188188-}
189189-190119// GetCrewHoldDID returns the hold DID from the user's most recent crew membership.
191120// Used as a fallback when the user's DefaultHoldDID is not cached.
192121func GetCrewHoldDID(db DBTX, memberDID string) string {
···342271 tier = excluded.tier,
343272 added_at = excluded.added_at,
344273 updated_at = CURRENT_TIMESTAMP
274274+ WHERE excluded.rkey != hold_crew_members.rkey
275275+ OR excluded.role IS NOT hold_crew_members.role
276276+ OR excluded.permissions IS NOT hold_crew_members.permissions
277277+ OR excluded.tier IS NOT hold_crew_members.tier
278278+ OR excluded.added_at IS NOT hold_crew_members.added_at
345279 `
346280347281 _, err := db.Exec(query,
···11+description: Add webhooks and scans tables for appview-side webhook management and scan caching
22+query: |
33+ CREATE TABLE IF NOT EXISTS webhooks (
44+ id TEXT PRIMARY KEY,
55+ user_did TEXT NOT NULL,
66+ url TEXT NOT NULL,
77+ secret TEXT,
88+ triggers INTEGER NOT NULL DEFAULT 1,
99+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
1010+ FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE
1111+ );
1212+ CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did);
1313+ CREATE TABLE IF NOT EXISTS scans (
1414+ hold_did TEXT NOT NULL,
1515+ manifest_digest TEXT NOT NULL,
1616+ user_did TEXT NOT NULL,
1717+ repository TEXT NOT NULL,
1818+ critical INTEGER NOT NULL DEFAULT 0,
1919+ high INTEGER NOT NULL DEFAULT 0,
2020+ medium INTEGER NOT NULL DEFAULT 0,
2121+ low INTEGER NOT NULL DEFAULT 0,
2222+ total INTEGER NOT NULL DEFAULT 0,
2323+ scanner_version TEXT,
2424+ scanned_at TIMESTAMP NOT NULL,
2525+ PRIMARY KEY(hold_did, manifest_digest)
2626+ );
2727+ CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
···11+description: Drop supporter_badge_tiers column (badges now determined by appview billing config)
22+query: |
33+ ALTER TABLE hold_captain_records DROP COLUMN supporter_badge_tiers;
+263-10
pkg/appview/db/queries.go
···44 "database/sql"
55 "encoding/json"
66 "fmt"
77+ "net/url"
78 "strings"
89 "time"
910)
···369370 return &user, nil
370371}
371372373373+// InsertUserIfNotExists inserts a user record only if it doesn't already exist.
374374+// Used by non-profile collections to avoid unnecessary writes during backfill.
375375+func InsertUserIfNotExists(db DBTX, user *User) error {
376376+ _, err := db.Exec(`
377377+ INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
378378+ VALUES (?, ?, ?, ?, ?)
379379+ ON CONFLICT(did) DO NOTHING
380380+ `, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
381381+ return err
382382+}
383383+372384// UpsertUser inserts or updates a user record
373385func UpsertUser(db DBTX, user *User) error {
374386 _, err := db.Exec(`
···592604 config_digest = excluded.config_digest,
593605 config_size = excluded.config_size,
594606 artifact_type = excluded.artifact_type
607607+ WHERE excluded.hold_endpoint != manifests.hold_endpoint
608608+ OR excluded.schema_version != manifests.schema_version
609609+ OR excluded.media_type != manifests.media_type
610610+ OR excluded.config_digest IS NOT manifests.config_digest
611611+ OR excluded.config_size IS NOT manifests.config_size
612612+ OR excluded.artifact_type != manifests.artifact_type
595613 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
596614 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
597615 manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt)
···614632 return id, nil
615633}
616634617617-// InsertLayer inserts or updates a layer record.
618618-// Uses upsert so backfill re-processing populates new columns (e.g. annotations).
635635+// InsertLayer inserts a layer record, skipping if it already exists.
636636+// Layers are immutable — once created, their digest/size/media_type never change.
619637func InsertLayer(db DBTX, layer *Layer) error {
620638 var annotationsJSON *string
621639 if len(layer.Annotations) > 0 {
···629647 _, err := db.Exec(`
630648 INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations)
631649 VALUES (?, ?, ?, ?, ?, ?)
632632- ON CONFLICT(manifest_id, layer_index) DO UPDATE SET
633633- digest = excluded.digest,
634634- size = excluded.size,
635635- media_type = excluded.media_type,
636636- annotations = excluded.annotations
650650+ ON CONFLICT(manifest_id, layer_index) DO NOTHING
637651 `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON)
638652 return err
639653}
···646660 ON CONFLICT(did, repository, tag) DO UPDATE SET
647661 digest = excluded.digest,
648662 created_at = excluded.created_at
663663+ WHERE excluded.digest != tags.digest
664664+ OR excluded.created_at != tags.created_at
649665 `, tag.DID, tag.Repository, tag.Tag, tag.Digest, tag.CreatedAt)
650666 return err
651667}
···16071623 last_pull = excluded.last_pull,
16081624 push_count = excluded.push_count,
16091625 last_push = excluded.last_push
16261626+ WHERE excluded.pull_count != repository_stats.pull_count
16271627+ OR excluded.last_pull IS NOT repository_stats.last_pull
16281628+ OR excluded.push_count != repository_stats.push_count
16291629+ OR excluded.last_push IS NOT repository_stats.last_push
16101630 `, stats.DID, stats.Repository, stats.PullCount, stats.LastPull, stats.PushCount, stats.LastPush)
16111631 return err
16121632}
1613163316141614-// UpsertStar inserts or updates a star record (idempotent)
16341634+// UpsertStar inserts a star record, skipping if it already exists.
16351635+// Stars are immutable — once created, they don't change.
16151636func UpsertStar(db DBTX, starrerDID, ownerDID, repository string, createdAt time.Time) error {
16161637 _, err := db.Exec(`
16171638 INSERT INTO stars (starrer_did, owner_did, repository, created_at)
16181639 VALUES (?, ?, ?, ?)
16191619- ON CONFLICT(starrer_did, owner_did, repository) DO UPDATE SET
16201620- created_at = excluded.created_at
16401640+ ON CONFLICT(starrer_did, owner_did, repository) DO NOTHING
16211641 `, starrerDID, ownerDID, repository, createdAt)
16221642 return err
16231643}
···19571977 description = excluded.description,
19581978 avatar_cid = excluded.avatar_cid,
19591979 updated_at = excluded.updated_at
19801980+ WHERE excluded.description IS NOT repo_pages.description
19811981+ OR excluded.avatar_cid IS NOT repo_pages.avatar_cid
19601982 `, did, repository, description, avatarCID, createdAt, updatedAt)
19611983 return err
19621984}
···20052027 }
20062028 return pages, rows.Err()
20072029}
20302030+20312031+// --- Webhook types and queries ---
20322032+20332033+// Webhook represents a webhook configuration stored in the appview DB
20342034+type Webhook struct {
20352035+ ID string `json:"id"`
20362036+ UserDID string `json:"userDid"`
20372037+ URL string `json:"url"`
20382038+ Secret string `json:"-"`
20392039+ Triggers int `json:"triggers"`
20402040+ HasSecret bool `json:"hasSecret"`
20412041+ CreatedAt time.Time `json:"createdAt"`
20422042+}
20432043+20442044+// CountWebhooks returns the number of webhooks configured for a user
20452045+func CountWebhooks(db DBTX, userDID string) (int, error) {
20462046+ var count int
20472047+ err := db.QueryRow(`SELECT COUNT(*) FROM webhooks WHERE user_did = ?`, userDID).Scan(&count)
20482048+ return count, err
20492049+}
20502050+20512051+// ListWebhooks returns webhook configurations for display (masked URLs, no secrets)
20522052+func ListWebhooks(db DBTX, userDID string) ([]Webhook, error) {
20532053+ rows, err := db.Query(`
20542054+ SELECT id, user_did, url, secret, triggers, created_at
20552055+ FROM webhooks WHERE user_did = ? ORDER BY created_at ASC
20562056+ `, userDID)
20572057+ if err != nil {
20582058+ return nil, err
20592059+ }
20602060+ defer rows.Close()
20612061+20622062+ var webhooks []Webhook
20632063+ for rows.Next() {
20642064+ var w Webhook
20652065+ var secret string
20662066+ if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &secret, &w.Triggers, &w.CreatedAt); err != nil {
20672067+ continue
20682068+ }
20692069+ w.HasSecret = secret != ""
20702070+ w.URL = maskWebhookURL(w.URL)
20712071+ webhooks = append(webhooks, w)
20722072+ }
20732073+ if webhooks == nil {
20742074+ webhooks = []Webhook{}
20752075+ }
20762076+ return webhooks, rows.Err()
20772077+}
20782078+20792079+// GetWebhookByID returns a single webhook with full URL and secret (for dispatch/test)
20802080+func GetWebhookByID(db DBTX, id string) (*Webhook, error) {
20812081+ var w Webhook
20822082+ err := db.QueryRow(`
20832083+ SELECT id, user_did, url, secret, triggers, created_at
20842084+ FROM webhooks WHERE id = ?
20852085+ `, id).Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt)
20862086+ if err != nil {
20872087+ return nil, err
20882088+ }
20892089+ w.HasSecret = w.Secret != ""
20902090+ return &w, nil
20912091+}
20922092+20932093+// InsertWebhook creates a new webhook record
20942094+func InsertWebhook(db DBTX, w *Webhook) error {
20952095+ _, err := db.Exec(`
20962096+ INSERT INTO webhooks (id, user_did, url, secret, triggers, created_at)
20972097+ VALUES (?, ?, ?, ?, ?, ?)
20982098+ `, w.ID, w.UserDID, w.URL, w.Secret, w.Triggers, w.CreatedAt)
20992099+ return err
21002100+}
21012101+21022102+// DeleteWebhook deletes a webhook by ID, validating ownership
21032103+func DeleteWebhook(db DBTX, id, userDID string) error {
21042104+ result, err := db.Exec(`DELETE FROM webhooks WHERE id = ? AND user_did = ?`, id, userDID)
21052105+ if err != nil {
21062106+ return err
21072107+ }
21082108+ rows, _ := result.RowsAffected()
21092109+ if rows == 0 {
21102110+ return fmt.Errorf("webhook not found or not owned by user")
21112111+ }
21122112+ return nil
21132113+}
21142114+21152115+// GetWebhooksForUser returns all webhooks with full URL+secret for dispatch
21162116+func GetWebhooksForUser(db DBTX, userDID string) ([]Webhook, error) {
21172117+ rows, err := db.Query(`
21182118+ SELECT id, user_did, url, secret, triggers, created_at
21192119+ FROM webhooks WHERE user_did = ?
21202120+ `, userDID)
21212121+ if err != nil {
21222122+ return nil, err
21232123+ }
21242124+ defer rows.Close()
21252125+21262126+ var webhooks []Webhook
21272127+ for rows.Next() {
21282128+ var w Webhook
21292129+ if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt); err != nil {
21302130+ continue
21312131+ }
21322132+ w.HasSecret = w.Secret != ""
21332133+ webhooks = append(webhooks, w)
21342134+ }
21352135+ return webhooks, rows.Err()
21362136+}
21372137+21382138+// maskWebhookURL masks a URL for display (shows scheme + host, hides path/query)
21392139+func maskWebhookURL(rawURL string) string {
21402140+ u, err := url.Parse(rawURL)
21412141+ if err != nil {
21422142+ if len(rawURL) > 30 {
21432143+ return rawURL[:30] + "***"
21442144+ }
21452145+ return rawURL
21462146+ }
21472147+ masked := u.Scheme + "://" + u.Host
21482148+ if u.Path != "" && u.Path != "/" {
21492149+ masked += "/***"
21502150+ }
21512151+ return masked
21522152+}
21532153+21542154+// --- Scan types and queries ---
21552155+21562156+// Scan represents a cached scan record from Jetstream
21572157+type Scan struct {
21582158+ HoldDID string
21592159+ ManifestDigest string
21602160+ UserDID string
21612161+ Repository string
21622162+ Critical int
21632163+ High int
21642164+ Medium int
21652165+ Low int
21662166+ Total int
21672167+ ScannerVersion string
21682168+ ScannedAt time.Time
21692169+}
21702170+21712171+// UpsertScan inserts or updates a scan record, returning the previous scan for change detection
21722172+func UpsertScan(db DBTX, scan *Scan) (*Scan, error) {
21732173+ // Fetch previous scan (if any) before upserting
21742174+ var prev *Scan
21752175+ var p Scan
21762176+ err := db.QueryRow(`
21772177+ SELECT hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at
21782178+ FROM scans WHERE hold_did = ? AND manifest_digest = ?
21792179+ `, scan.HoldDID, scan.ManifestDigest).Scan(
21802180+ &p.HoldDID, &p.ManifestDigest, &p.UserDID, &p.Repository,
21812181+ &p.Critical, &p.High, &p.Medium, &p.Low, &p.Total,
21822182+ &p.ScannerVersion, &p.ScannedAt,
21832183+ )
21842184+ if err == nil {
21852185+ prev = &p
21862186+ }
21872187+21882188+ // Upsert the new scan
21892189+ _, err = db.Exec(`
21902190+ INSERT INTO scans (hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at)
21912191+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
21922192+ ON CONFLICT(hold_did, manifest_digest) DO UPDATE SET
21932193+ user_did = excluded.user_did,
21942194+ repository = excluded.repository,
21952195+ critical = excluded.critical,
21962196+ high = excluded.high,
21972197+ medium = excluded.medium,
21982198+ low = excluded.low,
21992199+ total = excluded.total,
22002200+ scanner_version = excluded.scanner_version,
22012201+ scanned_at = excluded.scanned_at
22022202+ WHERE excluded.critical != scans.critical
22032203+ OR excluded.high != scans.high
22042204+ OR excluded.medium != scans.medium
22052205+ OR excluded.low != scans.low
22062206+ OR excluded.total != scans.total
22072207+ OR excluded.scanner_version IS NOT scans.scanner_version
22082208+ OR excluded.scanned_at != scans.scanned_at
22092209+ `, scan.HoldDID, scan.ManifestDigest, scan.UserDID, scan.Repository,
22102210+ scan.Critical, scan.High, scan.Medium, scan.Low, scan.Total,
22112211+ scan.ScannerVersion, scan.ScannedAt,
22122212+ )
22132213+ if err != nil {
22142214+ return nil, fmt.Errorf("failed to upsert scan: %w", err)
22152215+ }
22162216+22172217+ return prev, nil
22182218+}
22192219+22202220+// GetTagByDigest returns the most recent tag for a manifest digest in a user's repository
22212221+func GetTagByDigest(db DBTX, userDID, repository, digest string) (string, error) {
22222222+ var tag string
22232223+ err := db.QueryRow(`
22242224+ SELECT tag FROM tags
22252225+ WHERE did = ? AND repository = ? AND digest = ?
22262226+ ORDER BY created_at DESC LIMIT 1
22272227+ `, userDID, repository, digest).Scan(&tag)
22282228+ if err != nil {
22292229+ return "", err
22302230+ }
22312231+ return tag, nil
22322232+}
22332233+22342234+// IsHoldCaptain returns true if userDID is the owner of any hold in the managedHolds list.
22352235+func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error) {
22362236+ if userDID == "" || len(managedHolds) == 0 {
22372237+ return false, nil
22382238+ }
22392239+22402240+ placeholders := make([]string, len(managedHolds))
22412241+ args := make([]any, 0, len(managedHolds)+1)
22422242+ args = append(args, userDID)
22432243+ for i, did := range managedHolds {
22442244+ placeholders[i] = "?"
22452245+ args = append(args, did)
22462246+ }
22472247+22482248+ var exists int
22492249+ err := db.QueryRow(
22502250+ `SELECT 1 FROM hold_captain_records WHERE owner_did = ? AND hold_did IN (`+strings.Join(placeholders, ",")+`) LIMIT 1`,
22512251+ args...,
22522252+ ).Scan(&exists)
22532253+ if err == sql.ErrNoRows {
22542254+ return false, nil
22552255+ }
22562256+ if err != nil {
22572257+ return false, err
22582258+ }
22592259+ return true, nil
22602260+}
+27-1
pkg/appview/db/schema.sql
···186186 deployed_at TEXT,
187187 region TEXT,
188188 successor TEXT,
189189- supporter_badge_tiers TEXT,
190189 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
191190);
192191CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
···245244 key_data BLOB NOT NULL,
246245 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
247246);
247247+248248+CREATE TABLE IF NOT EXISTS webhooks (
249249+ id TEXT PRIMARY KEY,
250250+ user_did TEXT NOT NULL,
251251+ url TEXT NOT NULL,
252252+ secret TEXT,
253253+ triggers INTEGER NOT NULL DEFAULT 1,
254254+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
255255+ FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE
256256+);
257257+CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did);
258258+259259+CREATE TABLE IF NOT EXISTS scans (
260260+ hold_did TEXT NOT NULL,
261261+ manifest_digest TEXT NOT NULL,
262262+ user_did TEXT NOT NULL,
263263+ repository TEXT NOT NULL,
264264+ critical INTEGER NOT NULL DEFAULT 0,
265265+ high INTEGER NOT NULL DEFAULT 0,
266266+ medium INTEGER NOT NULL DEFAULT 0,
267267+ low INTEGER NOT NULL DEFAULT 0,
268268+ total INTEGER NOT NULL DEFAULT 0,
269269+ scanner_version TEXT,
270270+ scanned_at TIMESTAMP NOT NULL,
271271+ PRIMARY KEY(hold_did, manifest_digest)
272272+);
273273+CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
···6464 // RepoPageCollection is the collection name for repository page metadata
6565 // Stored in user's PDS with rkey = repository name
6666 RepoPageCollection = "io.atcr.repo.page"
6767-6868- // SailorWebhookCollection is the collection name for webhook configs in user's PDS
6969- SailorWebhookCollection = "io.atcr.sailor.webhook"
7070-7171- // WebhookCollection is the collection name for webhook records in hold's embedded PDS
7272- WebhookCollection = "io.atcr.hold.webhook"
7367)
74687569// ManifestRecord represents a container image manifest stored in ATProto
···667661// Stored in the hold's embedded PDS to identify the hold owner and settings
668662// Uses CBOR encoding for efficient storage in hold's carstore
669663type CaptainRecord struct {
670670- Type string `json:"$type" cborgen:"$type"`
671671- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
672672- Public bool `json:"public" cborgen:"public"` // Public read access
673673- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
674674- EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
675675- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
676676- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
677677- Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
678678- SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
664664+ Type string `json:"$type" cborgen:"$type"`
665665+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
666666+ Public bool `json:"public" cborgen:"public"` // Public read access
667667+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
668668+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
669669+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
670670+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
671671+ Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
679672}
680673681674// CrewRecord represents a crew member in the hold
···820813func ScanRecordKey(manifestDigest string) string {
821814 // Remove the "sha256:" prefix - the hex digest is already a valid rkey
822815 return strings.TrimPrefix(manifestDigest, "sha256:")
823823-}
824824-825825-// Webhook trigger bitmask constants
826826-const (
827827- TriggerFirst = 0x01 // First-time scan (no previous scan record)
828828- TriggerAll = 0x02 // Every scan completion
829829- TriggerChanged = 0x04 // Vulnerability counts changed from previous
830830-)
831831-832832-// SailorWebhookRecord represents a webhook config in the user's PDS
833833-// Links to a private HoldWebhookRecord via privateCid
834834-type SailorWebhookRecord struct {
835835- Type string `json:"$type"`
836836- HoldDID string `json:"holdDid"`
837837- Triggers int `json:"triggers"`
838838- PrivateCID string `json:"privateCid"`
839839- CreatedAt string `json:"createdAt"`
840840- UpdatedAt string `json:"updatedAt"`
841841-}
842842-843843-// NewSailorWebhookRecord creates a new sailor webhook record
844844-func NewSailorWebhookRecord(holdDID string, triggers int, privateCID string) *SailorWebhookRecord {
845845- now := time.Now().Format(time.RFC3339)
846846- return &SailorWebhookRecord{
847847- Type: SailorWebhookCollection,
848848- HoldDID: holdDID,
849849- Triggers: triggers,
850850- PrivateCID: privateCID,
851851- CreatedAt: now,
852852- UpdatedAt: now,
853853- }
854854-}
855855-856856-// HoldWebhookRecord represents a webhook record in the hold's embedded PDS
857857-// The actual URL and secret are stored in SQLite (never in ATProto records)
858858-type HoldWebhookRecord struct {
859859- Type string `json:"$type" cborgen:"$type"`
860860- UserDID string `json:"userDid" cborgen:"userDid"`
861861- Triggers int64 `json:"triggers" cborgen:"triggers"`
862862- CreatedAt string `json:"createdAt" cborgen:"createdAt"`
863863-}
864864-865865-// NewHoldWebhookRecord creates a new hold webhook record
866866-func NewHoldWebhookRecord(userDID string, triggers int) *HoldWebhookRecord {
867867- return &HoldWebhookRecord{
868868- Type: WebhookCollection,
869869- UserDID: userDID,
870870- Triggers: int64(triggers),
871871- CreatedAt: time.Now().Format(time.RFC3339),
872872- }
873816}
874817875818// TangledProfileRecord represents a Tangled profile for the hold
+77
pkg/auth/appview_token.go
···11+package auth
22+33+import (
44+ "crypto/ecdh"
55+ "crypto/ecdsa"
66+ "crypto/x509"
77+ "fmt"
88+ "time"
99+1010+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1111+ "github.com/golang-jwt/jwt/v5"
1212+)
1313+1414+// CreateAppviewServiceToken creates a short-lived ES256 JWT for appview→hold communication.
1515+// The token authenticates the appview when calling hold XRPC endpoints like updateCrewTier.
1616+//
1717+// Claims:
1818+// - iss: appview DID (e.g. did:web:atcr.io)
1919+// - aud: hold DID (e.g. did:web:hold01.atcr.io)
2020+// - sub: user DID being acted upon
2121+// - exp: now + 60s
2222+// - iat: now
2323+func CreateAppviewServiceToken(privateKey *atcrypto.PrivateKeyP256, appviewDID, holdDID, userDID string) (string, error) {
2424+ now := time.Now()
2525+2626+ claims := jwt.RegisteredClaims{
2727+ Issuer: appviewDID,
2828+ Audience: jwt.ClaimStrings{holdDID},
2929+ Subject: userDID,
3030+ ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)),
3131+ IssuedAt: jwt.NewNumericDate(now),
3232+ }
3333+3434+ token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
3535+3636+ ecKey, err := P256ToECDSA(privateKey)
3737+ if err != nil {
3838+ return "", fmt.Errorf("failed to extract ECDSA key: %w", err)
3939+ }
4040+4141+ signed, err := token.SignedString(ecKey)
4242+ if err != nil {
4343+ return "", fmt.Errorf("failed to sign token: %w", err)
4444+ }
4545+4646+ return signed, nil
4747+}
4848+4949+// P256ToECDSA converts an atcrypto P-256 private key to a stdlib *ecdsa.PrivateKey.
5050+// This is needed because golang-jwt requires stdlib crypto types, while atcrypto
5151+// wraps them in its own types. We re-parse via PKCS8 encoding round-trip.
5252+func P256ToECDSA(key *atcrypto.PrivateKeyP256) (*ecdsa.PrivateKey, error) {
5353+ rawBytes := key.Bytes() // 32-byte raw scalar
5454+5555+ // Parse raw bytes as ecdh key, then convert via PKCS8 round-trip (same as atcrypto does)
5656+ ecdhKey, err := ecdh.P256().NewPrivateKey(rawBytes)
5757+ if err != nil {
5858+ return nil, fmt.Errorf("failed to parse P-256 raw bytes: %w", err)
5959+ }
6060+6161+ pkcs8, err := x509.MarshalPKCS8PrivateKey(ecdhKey)
6262+ if err != nil {
6363+ return nil, fmt.Errorf("failed to marshal PKCS8: %w", err)
6464+ }
6565+6666+ parsed, err := x509.ParsePKCS8PrivateKey(pkcs8)
6767+ if err != nil {
6868+ return nil, fmt.Errorf("failed to parse PKCS8: %w", err)
6969+ }
7070+7171+ ecdsaKey, ok := parsed.(*ecdsa.PrivateKey)
7272+ if !ok {
7373+ return nil, fmt.Errorf("parsed key is not ECDSA")
7474+ }
7575+7676+ return ecdsaKey, nil
7777+}
+730
pkg/billing/billing.go
···11+//go:build billing
22+33+package billing
44+55+import (
66+ "context"
77+ "encoding/json"
88+ "fmt"
99+ "io"
1010+ "log/slog"
1111+ "net/http"
1212+ "os"
1313+ "strings"
1414+ "sync"
1515+ "time"
1616+1717+ "atcr.io/pkg/appview/holdclient"
1818+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1919+2020+ "github.com/stripe/stripe-go/v84"
2121+ portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
2222+ "github.com/stripe/stripe-go/v84/checkout/session"
2323+ "github.com/stripe/stripe-go/v84/customer"
2424+ "github.com/stripe/stripe-go/v84/price"
2525+ "github.com/stripe/stripe-go/v84/subscription"
2626+ "github.com/stripe/stripe-go/v84/webhook"
2727+)
2828+2929+// Manager handles Stripe billing and pushes tier updates to managed holds.
3030+type Manager struct {
3131+ cfg *Config
3232+ privateKey *atcrypto.PrivateKeyP256
3333+ appviewDID string
3434+ managedHolds []string
3535+ baseURL string
3636+ stripeKey string
3737+ webhookSecret string
3838+3939+ // Captain checker: bypasses billing for hold owners
4040+ captainChecker CaptainChecker
4141+4242+ // Customer cache: DID → Stripe customer
4343+ customerCache map[string]*cachedCustomer
4444+ customerCacheMu sync.RWMutex
4545+4646+ // Price cache: Stripe price ID → unit amount in cents
4747+ priceCache map[string]*cachedPrice
4848+ priceCacheMu sync.RWMutex
4949+5050+ // Hold tier cache: holdDID → tier list
5151+ holdTierCache map[string]*cachedHoldTiers
5252+ holdTierCacheMu sync.RWMutex
5353+}
5454+5555+type cachedHoldTiers struct {
5656+ tiers []holdclient.HoldTierInfo
5757+ expiresAt time.Time
5858+}
5959+6060+type cachedCustomer struct {
6161+ customer *stripe.Customer
6262+ expiresAt time.Time
6363+}
6464+6565+type cachedPrice struct {
6666+ unitAmount int64
6767+ expiresAt time.Time
6868+}
6969+7070+const customerCacheTTL = 10 * time.Minute
7171+const priceCacheTTL = 1 * time.Hour
7272+7373+// New creates a new billing manager with Stripe integration.
7474+// Env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take precedence over config values.
7575+func New(cfg *Config, privateKey *atcrypto.PrivateKeyP256, appviewDID string, managedHolds []string, baseURL string) *Manager {
7676+ stripeKey := os.Getenv("STRIPE_SECRET_KEY")
7777+ if stripeKey == "" {
7878+ stripeKey = cfg.StripeSecretKey
7979+ }
8080+ if stripeKey != "" {
8181+ stripe.Key = stripeKey
8282+ }
8383+8484+ webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
8585+ if webhookSecret == "" {
8686+ webhookSecret = cfg.WebhookSecret
8787+ }
8888+8989+ return &Manager{
9090+ cfg: cfg,
9191+ privateKey: privateKey,
9292+ appviewDID: appviewDID,
9393+ managedHolds: managedHolds,
9494+ baseURL: baseURL,
9595+ stripeKey: stripeKey,
9696+ webhookSecret: webhookSecret,
9797+ customerCache: make(map[string]*cachedCustomer),
9898+ priceCache: make(map[string]*cachedPrice),
9999+ holdTierCache: make(map[string]*cachedHoldTiers),
100100+ }
101101+}
102102+103103+// SetCaptainChecker sets a callback that checks if a user is a hold captain.
104104+// Captains bypass all billing feature gates.
105105+func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
106106+ m.captainChecker = fn
107107+}
108108+109109+func (m *Manager) isCaptain(userDID string) bool {
110110+ return m.captainChecker != nil && userDID != "" && m.captainChecker(userDID)
111111+}
112112+113113+// Enabled returns true if billing is properly configured.
114114+func (m *Manager) Enabled() bool {
115115+ return m.cfg != nil && m.stripeKey != "" && len(m.cfg.Tiers) > 0
116116+}
117117+118118+// GetWebhookLimits returns webhook limits for a user based on their subscription tier.
119119+// Returns (maxWebhooks, allTriggers). Defaults to the lowest tier's limits.
120120+// Hold captains get unlimited webhooks with all triggers.
121121+func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
122122+ if m.isCaptain(userDID) {
123123+ return -1, true // unlimited
124124+ }
125125+ if !m.Enabled() {
126126+ return 1, false
127127+ }
128128+129129+ info, err := m.GetSubscriptionInfo(userDID)
130130+ if err != nil || info == nil {
131131+ return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
132132+ }
133133+134134+ rank := info.TierRank
135135+ if rank >= 0 && rank < len(m.cfg.Tiers) {
136136+ return m.cfg.Tiers[rank].MaxWebhooks, m.cfg.Tiers[rank].WebhookAllTriggers
137137+ }
138138+139139+ return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
140140+}
141141+142142+// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription.
143143+// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise.
144144+// Hold captains get a "Captain" badge.
145145+func (m *Manager) GetSupporterBadge(userDID string) string {
146146+ if m.isCaptain(userDID) {
147147+ return "Captain"
148148+ }
149149+ if !m.Enabled() {
150150+ return ""
151151+ }
152152+153153+ info, err := m.GetSubscriptionInfo(userDID)
154154+ if err != nil || info == nil {
155155+ return ""
156156+ }
157157+158158+ for _, tier := range info.Tiers {
159159+ if tier.ID == info.CurrentTier && tier.SupporterBadge {
160160+ return info.CurrentTier
161161+ }
162162+ }
163163+164164+ return ""
165165+}
166166+167167+// GetSubscriptionInfo returns subscription and tier information for a user.
168168+// Hold captains see a special "Captain" tier with all features unlocked.
169169+func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
170170+ if m.isCaptain(userDID) {
171171+ return &SubscriptionInfo{
172172+ UserDID: userDID,
173173+ CurrentTier: "Captain",
174174+ TierRank: -1, // above all configured tiers
175175+ Tiers: []TierInfo{{
176176+ ID: "Captain",
177177+ Name: "Captain",
178178+ Description: "Hold operator",
179179+ Features: []string{"Unlimited storage", "Unlimited webhooks", "All webhook triggers", "Scan on push"},
180180+ Rank: -1,
181181+ MaxWebhooks: -1,
182182+ WebhookAllTriggers: true,
183183+ SupporterBadge: true,
184184+ IsCurrent: true,
185185+ }},
186186+ }, nil
187187+ }
188188+189189+ if !m.Enabled() {
190190+ return nil, ErrBillingDisabled
191191+ }
192192+193193+ info := &SubscriptionInfo{
194194+ UserDID: userDID,
195195+ PaymentsEnabled: true,
196196+ CurrentTier: m.cfg.Tiers[0].Name, // default to lowest
197197+ TierRank: 0,
198198+ }
199199+200200+ // Build tier list with live Stripe prices
201201+ info.Tiers = make([]TierInfo, len(m.cfg.Tiers))
202202+ for i, tier := range m.cfg.Tiers {
203203+ // Dynamic features: hold-derived first, then webhook limits, then static config
204204+ features := m.aggregateHoldFeatures(i)
205205+ features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...)
206206+ if tier.SupporterBadge {
207207+ features = append(features, "Supporter badge")
208208+ }
209209+ features = append(features, tier.Features...)
210210+ info.Tiers[i] = TierInfo{
211211+ ID: tier.Name,
212212+ Name: tier.Name,
213213+ Description: tier.Description,
214214+ Features: features,
215215+ Rank: i,
216216+ MaxWebhooks: tier.MaxWebhooks,
217217+ WebhookAllTriggers: tier.WebhookAllTriggers,
218218+ SupporterBadge: tier.SupporterBadge,
219219+ }
220220+ if tier.StripePriceMonthly != "" {
221221+ if amount, err := m.fetchPrice(tier.StripePriceMonthly); err == nil {
222222+ info.Tiers[i].PriceCentsMonthly = int(amount)
223223+ }
224224+ }
225225+ if tier.StripePriceYearly != "" {
226226+ if amount, err := m.fetchPrice(tier.StripePriceYearly); err == nil {
227227+ info.Tiers[i].PriceCentsYearly = int(amount)
228228+ }
229229+ }
230230+ }
231231+232232+ if userDID == "" {
233233+ return info, nil
234234+ }
235235+236236+ // Find Stripe customer for this user
237237+ cust, err := m.findCustomerByDID(userDID)
238238+ if err != nil {
239239+ slog.Debug("No Stripe customer found", "userDID", userDID, "error", err)
240240+ return info, nil
241241+ }
242242+ info.CustomerID = cust.ID
243243+244244+ // Find active subscription
245245+ params := &stripe.SubscriptionListParams{}
246246+ params.Filters.AddFilter("customer", "", cust.ID)
247247+ params.Filters.AddFilter("status", "", "active")
248248+ iter := subscription.List(params)
249249+250250+ for iter.Next() {
251251+ sub := iter.Subscription()
252252+ info.SubscriptionID = sub.ID
253253+254254+ if sub.Items != nil && len(sub.Items.Data) > 0 {
255255+ priceID := sub.Items.Data[0].Price.ID
256256+ tierName, tierRank := m.cfg.GetTierByPriceID(priceID)
257257+ if tierName != "" {
258258+ info.CurrentTier = tierName
259259+ info.TierRank = tierRank
260260+ }
261261+262262+ if sub.Items.Data[0].Price.Recurring != nil {
263263+ switch sub.Items.Data[0].Price.Recurring.Interval {
264264+ case stripe.PriceRecurringIntervalMonth:
265265+ info.BillingInterval = "monthly"
266266+ case stripe.PriceRecurringIntervalYear:
267267+ info.BillingInterval = "yearly"
268268+ }
269269+ }
270270+ }
271271+ break
272272+ }
273273+274274+ // Mark current tier
275275+ for i := range info.Tiers {
276276+ info.Tiers[i].IsCurrent = info.Tiers[i].ID == info.CurrentTier
277277+ }
278278+279279+ return info, nil
280280+}
281281+282282+// CreateCheckoutSession creates a Stripe checkout session for a subscription.
283283+func (m *Manager) CreateCheckoutSession(r *http.Request, userDID, userHandle string, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
284284+ if !m.Enabled() {
285285+ return nil, ErrBillingDisabled
286286+ }
287287+288288+ // Find the tier config
289289+ rank := m.cfg.TierRank(req.Tier)
290290+ if rank < 0 {
291291+ return nil, fmt.Errorf("unknown tier: %s", req.Tier)
292292+ }
293293+ tierCfg := m.cfg.Tiers[rank]
294294+295295+ // Determine price ID: prefer monthly so Stripe upsell can offer yearly toggle,
296296+ // fall back to yearly if no monthly price exists.
297297+ var priceID string
298298+ if req.Interval == "yearly" && tierCfg.StripePriceYearly != "" {
299299+ priceID = tierCfg.StripePriceYearly
300300+ } else if tierCfg.StripePriceMonthly != "" {
301301+ priceID = tierCfg.StripePriceMonthly
302302+ } else if tierCfg.StripePriceYearly != "" {
303303+ priceID = tierCfg.StripePriceYearly
304304+ }
305305+ if priceID == "" {
306306+ return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
307307+ }
308308+309309+ // Get or create Stripe customer
310310+ cust, err := m.getOrCreateCustomer(userDID, userHandle)
311311+ if err != nil {
312312+ return nil, fmt.Errorf("failed to get/create customer: %w", err)
313313+ }
314314+315315+ // Build success/cancel URLs
316316+ successURL := strings.ReplaceAll(m.cfg.SuccessURL, "{base_url}", m.baseURL)
317317+ cancelURL := strings.ReplaceAll(m.cfg.CancelURL, "{base_url}", m.baseURL)
318318+319319+ params := &stripe.CheckoutSessionParams{
320320+ Customer: stripe.String(cust.ID),
321321+ Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
322322+ LineItems: []*stripe.CheckoutSessionLineItemParams{
323323+ {
324324+ Price: stripe.String(priceID),
325325+ Quantity: stripe.Int64(1),
326326+ },
327327+ },
328328+ SuccessURL: stripe.String(successURL),
329329+ CancelURL: stripe.String(cancelURL),
330330+ }
331331+332332+ s, err := session.New(params)
333333+ if err != nil {
334334+ return nil, fmt.Errorf("failed to create checkout session: %w", err)
335335+ }
336336+337337+ return &CheckoutSessionResponse{
338338+ CheckoutURL: s.URL,
339339+ SessionID: s.ID,
340340+ }, nil
341341+}
342342+343343+// GetBillingPortalURL creates a Stripe billing portal session.
344344+func (m *Manager) GetBillingPortalURL(userDID, returnURL string) (*BillingPortalResponse, error) {
345345+ if !m.Enabled() {
346346+ return nil, ErrBillingDisabled
347347+ }
348348+349349+ cust, err := m.findCustomerByDID(userDID)
350350+ if err != nil {
351351+ return nil, fmt.Errorf("no billing account found")
352352+ }
353353+354354+ params := &stripe.BillingPortalSessionParams{
355355+ Customer: stripe.String(cust.ID),
356356+ ReturnURL: stripe.String(returnURL),
357357+ }
358358+359359+ s, err := portalsession.New(params)
360360+ if err != nil {
361361+ return nil, fmt.Errorf("failed to create portal session: %w", err)
362362+ }
363363+364364+ return &BillingPortalResponse{PortalURL: s.URL}, nil
365365+}
366366+367367+// HandleWebhook processes a Stripe webhook event.
368368+// On subscription changes, it pushes tier updates to all managed holds.
369369+func (m *Manager) HandleWebhook(r *http.Request) error {
370370+ if !m.Enabled() {
371371+ return ErrBillingDisabled
372372+ }
373373+374374+ body, err := io.ReadAll(r.Body)
375375+ if err != nil {
376376+ return fmt.Errorf("failed to read webhook body: %w", err)
377377+ }
378378+379379+ event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
380380+ if err != nil {
381381+ return fmt.Errorf("webhook signature verification failed: %w", err)
382382+ }
383383+384384+ switch event.Type {
385385+ case "checkout.session.completed":
386386+ m.handleCheckoutCompleted(event)
387387+ case "customer.subscription.created",
388388+ "customer.subscription.updated",
389389+ "customer.subscription.deleted",
390390+ "customer.subscription.paused",
391391+ "customer.subscription.resumed":
392392+ m.handleSubscriptionChange(event)
393393+ default:
394394+ slog.Debug("Ignoring Stripe event", "type", event.Type)
395395+ }
396396+397397+ return nil
398398+}
399399+400400+// handleCheckoutCompleted processes a checkout.session.completed event.
401401+func (m *Manager) handleCheckoutCompleted(event stripe.Event) {
402402+ var cs stripe.CheckoutSession
403403+ if err := json.Unmarshal(event.Data.Raw, &cs); err != nil {
404404+ slog.Error("Failed to parse checkout session", "error", err)
405405+ return
406406+ }
407407+408408+ slog.Info("Checkout completed", "customerID", cs.Customer.ID, "subscriptionID", cs.Subscription.ID)
409409+410410+ // The subscription.created event will handle the tier update
411411+}
412412+413413+// handleSubscriptionChange processes subscription lifecycle events.
414414+func (m *Manager) handleSubscriptionChange(event stripe.Event) {
415415+ var sub stripe.Subscription
416416+ if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
417417+ slog.Error("Failed to parse subscription", "error", err)
418418+ return
419419+ }
420420+421421+ // Get user DID from customer metadata
422422+ userDID := m.getCustomerDID(sub.Customer.ID)
423423+ if userDID == "" {
424424+ slog.Warn("No user DID found for Stripe customer", "customerID", sub.Customer.ID)
425425+ return
426426+ }
427427+428428+ // Determine new tier from subscription
429429+ var tierName string
430430+ var tierRank int
431431+432432+ switch sub.Status {
433433+ case stripe.SubscriptionStatusActive:
434434+ if sub.Items != nil && len(sub.Items.Data) > 0 {
435435+ priceID := sub.Items.Data[0].Price.ID
436436+ tierName, tierRank = m.cfg.GetTierByPriceID(priceID)
437437+ }
438438+ case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusPaused:
439439+ // Revert to free tier (rank 0)
440440+ tierName = m.cfg.Tiers[0].Name
441441+ tierRank = 0
442442+ default:
443443+ slog.Debug("Ignoring subscription status", "status", sub.Status)
444444+ return
445445+ }
446446+447447+ if tierName == "" {
448448+ slog.Warn("Could not resolve tier from subscription", "priceID", sub.Items.Data[0].Price.ID)
449449+ return
450450+ }
451451+452452+ slog.Info("Pushing tier update to managed holds",
453453+ "userDID", userDID,
454454+ "tierName", tierName,
455455+ "tierRank", tierRank,
456456+ "event", event.Type,
457457+ )
458458+459459+ // Push tier update to all managed holds
460460+ go holdclient.UpdateCrewTierOnAllHolds(
461461+ context.Background(),
462462+ m.managedHolds,
463463+ userDID,
464464+ tierRank,
465465+ m.privateKey,
466466+ m.appviewDID,
467467+ )
468468+469469+ // Invalidate customer cache
470470+ m.customerCacheMu.Lock()
471471+ delete(m.customerCache, userDID)
472472+ m.customerCacheMu.Unlock()
473473+}
474474+475475+// getOrCreateCustomer finds or creates a Stripe customer for a DID.
476476+func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) {
477477+ // Check cache
478478+ m.customerCacheMu.RLock()
479479+ if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
480480+ m.customerCacheMu.RUnlock()
481481+ return cached.customer, nil
482482+ }
483483+ m.customerCacheMu.RUnlock()
484484+485485+ // Search Stripe
486486+ cust, err := m.findCustomerByDID(userDID)
487487+ if err == nil {
488488+ m.cacheCustomer(userDID, cust)
489489+ return cust, nil
490490+ }
491491+492492+ // Create new customer
493493+ params := &stripe.CustomerParams{
494494+ Params: stripe.Params{
495495+ Metadata: map[string]string{
496496+ "user_did": userDID,
497497+ },
498498+ },
499499+ }
500500+ if userHandle != "" {
501501+ params.Name = stripe.String(userHandle)
502502+ }
503503+504504+ cust, err = customer.New(params)
505505+ if err != nil {
506506+ return nil, fmt.Errorf("failed to create Stripe customer: %w", err)
507507+ }
508508+509509+ m.cacheCustomer(userDID, cust)
510510+ return cust, nil
511511+}
512512+513513+// findCustomerByDID searches Stripe for a customer with matching DID metadata.
514514+func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
515515+ params := &stripe.CustomerSearchParams{
516516+ SearchParams: stripe.SearchParams{
517517+ Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
518518+ },
519519+ }
520520+521521+ iter := customer.Search(params)
522522+ for iter.Next() {
523523+ return iter.Customer(), nil
524524+ }
525525+526526+ return nil, fmt.Errorf("customer not found for DID %s", userDID)
527527+}
528528+529529+// getCustomerDID retrieves the user DID from a Stripe customer's metadata.
530530+func (m *Manager) getCustomerDID(customerID string) string {
531531+ cust, err := customer.Get(customerID, nil)
532532+ if err != nil {
533533+ slog.Error("Failed to get customer", "customerID", customerID, "error", err)
534534+ return ""
535535+ }
536536+ return cust.Metadata["user_did"]
537537+}
538538+539539+// cacheCustomer stores a customer in the in-memory cache.
540540+func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
541541+ m.customerCacheMu.Lock()
542542+ m.customerCache[userDID] = &cachedCustomer{
543543+ customer: cust,
544544+ expiresAt: time.Now().Add(customerCacheTTL),
545545+ }
546546+ m.customerCacheMu.Unlock()
547547+}
548548+549549+const holdTierCacheTTL = 30 * time.Minute
550550+551551+// RefreshHoldTiers queries all managed holds for their tier definitions and caches the results.
552552+// It runs once immediately (with retries for holds that aren't ready yet) and then
553553+// periodically in the background.
554554+// Safe to call from a goroutine.
555555+func (m *Manager) RefreshHoldTiers() {
556556+ if !m.Enabled() || len(m.managedHolds) == 0 {
557557+ return
558558+ }
559559+560560+ // On startup, retry a few times with backoff in case holds aren't ready yet.
561561+ // This is common in docker-compose where appview starts before the hold.
562562+ const maxRetries = 5
563563+ const initialDelay = 3 * time.Second
564564+565565+ for attempt := range maxRetries {
566566+ m.refreshHoldTiersOnce()
567567+568568+ // Check if all managed holds are cached
569569+ m.holdTierCacheMu.RLock()
570570+ allCached := len(m.holdTierCache) == len(m.managedHolds)
571571+ m.holdTierCacheMu.RUnlock()
572572+573573+ if allCached {
574574+ break
575575+ }
576576+577577+ if attempt < maxRetries-1 {
578578+ delay := initialDelay * time.Duration(1<<attempt) // 3s, 6s, 12s, 24s
579579+ slog.Info("Some managed holds not yet reachable, retrying",
580580+ "attempt", attempt+1, "maxRetries", maxRetries, "retryIn", delay)
581581+ time.Sleep(delay)
582582+ }
583583+ }
584584+585585+ ticker := time.NewTicker(holdTierCacheTTL)
586586+ defer ticker.Stop()
587587+ for range ticker.C {
588588+ m.refreshHoldTiersOnce()
589589+ }
590590+}
591591+592592+func (m *Manager) refreshHoldTiersOnce() {
593593+ for _, holdDID := range m.managedHolds {
594594+ resp, err := holdclient.ListTiers(context.Background(), holdDID)
595595+ if err != nil {
596596+ slog.Warn("Failed to fetch tiers from hold", "holdDID", holdDID, "error", err)
597597+ continue
598598+ }
599599+600600+ m.holdTierCacheMu.Lock()
601601+ m.holdTierCache[holdDID] = &cachedHoldTiers{
602602+ tiers: resp.Tiers,
603603+ expiresAt: time.Now().Add(holdTierCacheTTL),
604604+ }
605605+ m.holdTierCacheMu.Unlock()
606606+607607+ slog.Debug("Cached tier data from hold", "holdDID", holdDID, "tierCount", len(resp.Tiers))
608608+ }
609609+}
610610+611611+// aggregateHoldFeatures generates dynamic feature strings for a tier rank
612612+// by aggregating data from all cached managed holds.
613613+// Returns nil if no hold data is available.
614614+func (m *Manager) aggregateHoldFeatures(rank int) []string {
615615+ m.holdTierCacheMu.RLock()
616616+ defer m.holdTierCacheMu.RUnlock()
617617+618618+ if len(m.holdTierCache) == 0 {
619619+ return nil
620620+ }
621621+622622+ var (
623623+ minQuota int64 = -1
624624+ maxQuota int64
625625+ scanCount int
626626+ totalHolds int
627627+ )
628628+629629+ for _, cached := range m.holdTierCache {
630630+ if time.Now().After(cached.expiresAt) {
631631+ continue
632632+ }
633633+ if rank >= len(cached.tiers) {
634634+ continue
635635+ }
636636+ totalHolds++
637637+ tier := cached.tiers[rank]
638638+639639+ if minQuota < 0 || tier.QuotaBytes < minQuota {
640640+ minQuota = tier.QuotaBytes
641641+ }
642642+ if tier.QuotaBytes > maxQuota {
643643+ maxQuota = tier.QuotaBytes
644644+ }
645645+ if tier.ScanOnPush {
646646+ scanCount++
647647+ }
648648+ }
649649+650650+ if totalHolds == 0 {
651651+ return nil
652652+ }
653653+654654+ var features []string
655655+656656+ // Storage feature
657657+ if minQuota == maxQuota {
658658+ features = append(features, formatBytes(minQuota)+" storage")
659659+ } else {
660660+ features = append(features, formatBytes(minQuota)+"-"+formatBytes(maxQuota)+" storage")
661661+ }
662662+663663+ // Scan on push feature
664664+ if scanCount == totalHolds {
665665+ features = append(features, "Scan on push")
666666+ } else if scanCount*2 >= totalHolds {
667667+ features = append(features, "Scan on push (most regions)")
668668+ } else if scanCount > 0 {
669669+ features = append(features, "Scan on push (some regions)")
670670+ }
671671+672672+ return features
673673+}
674674+675675+// webhookFeatures generates feature bullet strings for webhook limits.
676676+func webhookFeatures(maxWebhooks int, allTriggers bool) []string {
677677+ var features []string
678678+ switch {
679679+ case maxWebhooks < 0:
680680+ features = append(features, "Unlimited webhooks")
681681+ case maxWebhooks == 1:
682682+ features = append(features, "1 webhook")
683683+ case maxWebhooks > 1:
684684+ features = append(features, fmt.Sprintf("%d webhooks", maxWebhooks))
685685+ }
686686+ if allTriggers {
687687+ features = append(features, "All webhook triggers")
688688+ }
689689+ return features
690690+}
691691+692692+// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
693693+func formatBytes(b int64) string {
694694+ const unit = 1024
695695+ if b < unit {
696696+ return fmt.Sprintf("%d B", b)
697697+ }
698698+ div, exp := int64(unit), 0
699699+ for n := b / unit; n >= unit; n /= unit {
700700+ div *= unit
701701+ exp++
702702+ }
703703+ units := []string{"KB", "MB", "GB", "TB", "PB"}
704704+ return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
705705+}
706706+707707+// fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache.
708708+func (m *Manager) fetchPrice(priceID string) (int64, error) {
709709+ m.priceCacheMu.RLock()
710710+ if cached, ok := m.priceCache[priceID]; ok && time.Now().Before(cached.expiresAt) {
711711+ m.priceCacheMu.RUnlock()
712712+ return cached.unitAmount, nil
713713+ }
714714+ m.priceCacheMu.RUnlock()
715715+716716+ p, err := price.Get(priceID, nil)
717717+ if err != nil {
718718+ slog.Warn("Failed to fetch Stripe price", "priceID", priceID, "error", err)
719719+ return 0, err
720720+ }
721721+722722+ m.priceCacheMu.Lock()
723723+ m.priceCache[priceID] = &cachedPrice{
724724+ unitAmount: p.UnitAmount,
725725+ expiresAt: time.Now().Add(priceCacheTTL),
726726+ }
727727+ m.priceCacheMu.Unlock()
728728+729729+ return p.UnitAmount, nil
730730+}
+72
pkg/billing/billing_stub.go
···11+//go:build !billing
22+33+package billing
44+55+import (
66+ "net/http"
77+88+ "github.com/bluesky-social/indigo/atproto/atcrypto"
99+ "github.com/go-chi/chi/v5"
1010+)
1111+1212+// Manager is a no-op billing manager when billing is not compiled in.
1313+type Manager struct {
1414+ captainChecker CaptainChecker
1515+}
1616+1717+// New creates a no-op billing manager.
1818+func New(_ *Config, _ *atcrypto.PrivateKeyP256, _ string, _ []string, _ string) *Manager {
1919+ return &Manager{}
2020+}
2121+2222+// SetCaptainChecker sets a callback that checks if a user is a hold captain.
2323+func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
2424+ m.captainChecker = fn
2525+}
2626+2727+// Enabled returns false when billing is not compiled in.
2828+func (m *Manager) Enabled() bool { return false }
2929+3030+// GetWebhookLimits returns default limits when billing is not compiled in.
3131+// Hold captains get unlimited webhooks with all triggers.
3232+func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
3333+ if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
3434+ return -1, true
3535+ }
3636+ return 1, false
3737+}
3838+3939+// GetSubscriptionInfo returns an error when billing is not compiled in.
4040+func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
4141+ return nil, ErrBillingDisabled
4242+}
4343+4444+// CreateCheckoutSession returns an error when billing is not compiled in.
4545+func (m *Manager) CreateCheckoutSession(_ *http.Request, _, _ string, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
4646+ return nil, ErrBillingDisabled
4747+}
4848+4949+// GetBillingPortalURL returns an error when billing is not compiled in.
5050+func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
5151+ return nil, ErrBillingDisabled
5252+}
5353+5454+// HandleWebhook returns an error when billing is not compiled in.
5555+func (m *Manager) HandleWebhook(_ *http.Request) error {
5656+ return ErrBillingDisabled
5757+}
5858+5959+// GetSupporterBadge returns empty string when billing is not compiled in.
6060+// Hold captains get a "Captain" badge.
6161+func (m *Manager) GetSupporterBadge(userDID string) string {
6262+ if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
6363+ return "Captain"
6464+ }
6565+ return ""
6666+}
6767+6868+// RegisterRoutes is a no-op when billing is not compiled in.
6969+func (m *Manager) RegisterRoutes(_ chi.Router) {}
7070+7171+// RefreshHoldTiers is a no-op when billing is not compiled in.
7272+func (m *Manager) RefreshHoldTiers() {}
+83
pkg/billing/config.go
···11+package billing
22+33+// Config holds appview billing/Stripe configuration.
44+// Parsed from the appview config YAML's billing section.
55+type Config struct {
66+ // Stripe secret key (sk_test_... or sk_live_...).
77+ // Can also be set via STRIPE_SECRET_KEY env var (takes precedence over config).
88+ // Billing is enabled automatically when this key is set (requires -tags billing build).
99+ 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."`
1010+1111+ // Stripe webhook signing secret (whsec_...).
1212+ // Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence over config).
1313+ WebhookSecret string `yaml:"webhook_secret" comment:"Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence)."`
1414+1515+ // Currency code for Stripe checkout (e.g. "usd").
1616+ Currency string `yaml:"currency" comment:"ISO 4217 currency code (e.g. \"usd\")."`
1717+1818+ // URL to redirect after successful checkout. {base_url} is replaced at runtime.
1919+ SuccessURL string `yaml:"success_url" comment:"Redirect URL after successful checkout. Use {base_url} placeholder."`
2020+2121+ // URL to redirect after cancelled checkout. {base_url} is replaced at runtime.
2222+ CancelURL string `yaml:"cancel_url" comment:"Redirect URL after cancelled checkout. Use {base_url} placeholder."`
2323+2424+ // Subscription tiers with Stripe price IDs.
2525+ Tiers []BillingTierConfig `yaml:"tiers" comment:"Subscription tiers ordered by rank (lowest to highest)."`
2626+2727+ // Whether hold owners get a supporter badge on their profile.
2828+ OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on hold owner profiles."`
2929+}
3030+3131+// BillingTierConfig represents a single tier with optional Stripe pricing.
3232+type BillingTierConfig struct {
3333+ // Tier name (matches hold quota tier names for rank mapping).
3434+ Name string `yaml:"name" comment:"Tier name. Position in list determines rank (0-based)."`
3535+3636+ // Short description shown on the plan card.
3737+ Description string `yaml:"description,omitempty" comment:"Short description shown on the plan card."`
3838+3939+ // List of features included in this tier (rendered as bullet points).
4040+ Features []string `yaml:"features,omitempty" comment:"List of features included in this tier."`
4141+4242+ // Stripe price ID for monthly billing. Empty = free tier.
4343+ StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty" comment:"Stripe price ID for monthly billing. Empty = free tier."`
4444+4545+ // Stripe price ID for yearly billing.
4646+ StripePriceYearly string `yaml:"stripe_price_yearly,omitempty" comment:"Stripe price ID for yearly billing."`
4747+4848+ // Maximum number of webhooks for this tier (-1 = unlimited).
4949+ MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhooks for this tier (-1 = unlimited)."`
5050+5151+ // Whether all webhook trigger types are available (not just first-scan).
5252+ WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."`
5353+5454+ // Whether this tier earns a supporter badge on user profiles.
5555+ SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."`
5656+}
5757+5858+// GetTierByPriceID finds the tier that contains the given Stripe price ID.
5959+// Returns the tier name and rank, or empty string and -1 if not found.
6060+func (c *Config) GetTierByPriceID(priceID string) (string, int) {
6161+ if c == nil || priceID == "" {
6262+ return "", -1
6363+ }
6464+ for i, tier := range c.Tiers {
6565+ if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID {
6666+ return tier.Name, i
6767+ }
6868+ }
6969+ return "", -1
7070+}
7171+7272+// TierRank returns the 0-based rank of a tier by name, or -1 if not found.
7373+func (c *Config) TierRank(name string) int {
7474+ if c == nil {
7575+ return -1
7676+ }
7777+ for i, tier := range c.Tiers {
7878+ if tier.Name == name {
7979+ return i
8080+ }
8181+ }
8282+ return -1
8383+}
+57
pkg/billing/handlers.go
···11+//go:build billing
22+33+package billing
44+55+import (
66+ "encoding/json"
77+ "log/slog"
88+ "net/http"
99+1010+ "github.com/go-chi/chi/v5"
1111+)
1212+1313+// RegisterRoutes registers billing HTTP routes on the router.
1414+// These routes handle subscription management and Stripe webhooks.
1515+func (m *Manager) RegisterRoutes(r chi.Router) {
1616+ if !m.Enabled() {
1717+ slog.Info("Billing routes disabled (not configured)")
1818+ return
1919+ }
2020+2121+ slog.Info("Registering billing routes")
2222+2323+ // Stripe webhook (public, verified by Stripe signature)
2424+ r.Post("/api/stripe/webhook", m.handleStripeWebhook)
2525+}
2626+2727+// handleStripeWebhook processes incoming Stripe webhook events.
2828+func (m *Manager) handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
2929+ if err := m.HandleWebhook(r); err != nil {
3030+ slog.Error("Stripe webhook error", "error", err)
3131+ http.Error(w, err.Error(), http.StatusBadRequest)
3232+ return
3333+ }
3434+3535+ w.WriteHeader(http.StatusOK)
3636+ if _, err := w.Write([]byte(`{"received": true}`)); err != nil {
3737+ slog.Error("Failed to write webhook response", "error", err)
3838+ }
3939+}
4040+4141+// HandleGetSubscription is an HTTP handler that returns subscription info as JSON.
4242+// Used by the settings page HTMX endpoint.
4343+func (m *Manager) HandleGetSubscription(w http.ResponseWriter, r *http.Request, userDID string) {
4444+ info, err := m.GetSubscriptionInfo(userDID)
4545+ if err != nil {
4646+ w.WriteHeader(http.StatusOK)
4747+ if _, writeErr := w.Write([]byte("")); writeErr != nil {
4848+ slog.Error("Failed to write empty response", "error", writeErr)
4949+ }
5050+ return
5151+ }
5252+5353+ w.Header().Set("Content-Type", "application/json")
5454+ if err := json.NewEncoder(w).Encode(info); err != nil {
5555+ slog.Error("Failed to encode subscription info", "error", err)
5656+ }
5757+}
+59
pkg/billing/types.go
···11+// Package billing provides optional Stripe billing integration for the appview.
22+// Build with -tags billing to enable Stripe integration.
33+// Without the tag, no-op stubs are compiled instead.
44+package billing
55+66+import "errors"
77+88+// ErrBillingDisabled is returned when billing operations are attempted
99+// but billing is not compiled in or not configured.
1010+var ErrBillingDisabled = errors.New("billing not enabled")
1111+1212+// CaptainChecker returns true if a user DID is the captain (owner) of a managed hold.
1313+// Used to bypass billing feature gates for hold operators.
1414+type CaptainChecker func(userDID string) bool
1515+1616+// SubscriptionInfo contains subscription information for a user.
1717+type SubscriptionInfo struct {
1818+ UserDID string `json:"userDid"`
1919+ CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default)
2020+ TierRank int `json:"tierRank"` // 0-based rank index
2121+ PaymentsEnabled bool `json:"paymentsEnabled"` // whether billing is active
2222+ Tiers []TierInfo `json:"tiers"` // available tiers with pricing
2323+ SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active
2424+ CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists
2525+ BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly"
2626+}
2727+2828+// TierInfo describes a single tier available for subscription.
2929+type TierInfo struct {
3030+ ID string `json:"id"` // tier key
3131+ Name string `json:"name"` // display name
3232+ Description string `json:"description,omitempty"` // short description for the plan card
3333+ Features []string `json:"features,omitempty"` // feature bullet points
3434+ Rank int `json:"rank"` // 0-based rank
3535+ PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free)
3636+ PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available)
3737+ MaxWebhooks int `json:"maxWebhooks"` // max webhooks (-1 = unlimited)
3838+ WebhookAllTriggers bool `json:"webhookAllTriggers,omitempty"` // all trigger types available
3939+ SupporterBadge bool `json:"supporterBadge,omitempty"` // earns supporter badge on profile
4040+ IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier
4141+}
4242+4343+// CheckoutSessionRequest is the request to create a Stripe checkout session.
4444+type CheckoutSessionRequest struct {
4545+ Tier string `json:"tier"`
4646+ Interval string `json:"interval,omitempty"` // "monthly" or "yearly"
4747+ ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout
4848+}
4949+5050+// CheckoutSessionResponse is the response with the Stripe checkout URL.
5151+type CheckoutSessionResponse struct {
5252+ CheckoutURL string `json:"checkoutUrl"`
5353+ SessionID string `json:"sessionId"`
5454+}
5555+5656+// BillingPortalResponse is the response with the Stripe billing portal URL.
5757+type BillingPortalResponse struct {
5858+ PortalURL string `json:"portalUrl"`
5959+}
···11-//go:build billing
22-33-package billing
44-55-import (
66- "encoding/json"
77- "errors"
88- "fmt"
99- "io"
1010- "log/slog"
1111- "net/http"
1212- "os"
1313- "sort"
1414- "strings"
1515- "sync"
1616- "time"
1717-1818- "github.com/stripe/stripe-go/v84"
1919- portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
2020- "github.com/stripe/stripe-go/v84/checkout/session"
2121- "github.com/stripe/stripe-go/v84/customer"
2222- "github.com/stripe/stripe-go/v84/price"
2323- "github.com/stripe/stripe-go/v84/subscription"
2424- "github.com/stripe/stripe-go/v84/webhook"
2525-2626- "atcr.io/pkg/hold/quota"
2727-)
2828-2929-// Manager handles Stripe billing integration.
3030-type Manager struct {
3131- quotaMgr *quota.Manager
3232- billingCfg *BillingConfig
3333- holdPublicURL string
3434- stripeKey string
3535- webhookSecret string
3636- publishableKey string
3737-3838- // In-memory cache for customer lookups (DID -> customer)
3939- customerCache map[string]*cachedCustomer
4040- customerCacheMu sync.RWMutex
4141-}
4242-4343-type cachedCustomer struct {
4444- customer *stripe.Customer
4545- expiresAt time.Time
4646-}
4747-4848-const customerCacheTTL = 10 * time.Minute
4949-5050-// New creates a new billing manager with Stripe integration.
5151-// configPath is the path to the hold config YAML file (for billing config parsing).
5252-func New(quotaMgr *quota.Manager, holdPublicURL string, configPath string) *Manager {
5353- stripeKey := os.Getenv("STRIPE_SECRET_KEY")
5454- if stripeKey != "" {
5555- stripe.Key = stripeKey
5656- }
5757-5858- billingCfg, err := LoadBillingConfig(configPath)
5959- if err != nil {
6060- slog.Warn("Failed to load billing config", "error", err)
6161- }
6262-6363- // Validate billing tier names against quota tiers
6464- if billingCfg != nil && billingCfg.Enabled {
6565- for tierName := range billingCfg.Tiers {
6666- if quotaMgr.GetTierLimit(tierName) == nil && tierName != quotaMgr.GetDefaultTier() {
6767- slog.Warn("Billing tier has no matching quota tier", "tier", tierName)
6868- }
6969- }
7070- }
7171-7272- return &Manager{
7373- quotaMgr: quotaMgr,
7474- billingCfg: billingCfg,
7575- holdPublicURL: holdPublicURL,
7676- stripeKey: stripeKey,
7777- webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
7878- publishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
7979- customerCache: make(map[string]*cachedCustomer),
8080- }
8181-}
8282-8383-// Enabled returns true if billing is properly configured.
8484-func (m *Manager) Enabled() bool {
8585- return m.billingCfg != nil && m.billingCfg.Enabled && m.stripeKey != ""
8686-}
8787-8888-// GetSubscriptionInfo returns subscription and quota information for a user.
8989-func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
9090- if !m.Enabled() {
9191- return nil, ErrBillingDisabled
9292- }
9393-9494- info := &SubscriptionInfo{
9595- UserDID: userDID,
9696- PaymentsEnabled: true,
9797- Tiers: m.buildTierList(userDID),
9898- }
9999-100100- // Try to find existing customer
101101- cust, err := m.findCustomerByDID(userDID)
102102- if err != nil {
103103- slog.Debug("No Stripe customer found for user", "userDid", userDID)
104104- } else if cust != nil {
105105- info.CustomerID = cust.ID
106106-107107- // Get active subscription if any (check all nil pointers)
108108- if cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 {
109109- sub := cust.Subscriptions.Data[0]
110110- info.SubscriptionID = sub.ID
111111-112112- // Safely access subscription items
113113- if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0].Price != nil {
114114- info.CurrentTier = m.billingCfg.GetTierByPriceID(sub.Items.Data[0].Price.ID)
115115-116116- if sub.Items.Data[0].Price.Recurring != nil {
117117- switch sub.Items.Data[0].Price.Recurring.Interval {
118118- case stripe.PriceRecurringIntervalMonth:
119119- info.BillingInterval = "monthly"
120120- case stripe.PriceRecurringIntervalYear:
121121- info.BillingInterval = "yearly"
122122- }
123123- }
124124- }
125125- }
126126- }
127127-128128- // If no subscription, use default tier
129129- if info.CurrentTier == "" {
130130- info.CurrentTier = m.quotaMgr.GetDefaultTier()
131131- }
132132-133133- // Get quota limit for current tier
134134- limit := m.quotaMgr.GetTierLimit(info.CurrentTier)
135135- info.CurrentLimit = limit
136136-137137- // Mark current tier in tier list
138138- for i := range info.Tiers {
139139- if info.Tiers[i].ID == info.CurrentTier {
140140- info.Tiers[i].IsCurrent = true
141141- }
142142- }
143143-144144- return info, nil
145145-}
146146-147147-// buildTierList creates the list of available tiers by merging quota limits
148148-// from the quota manager with billing metadata from the billing config.
149149-func (m *Manager) buildTierList(userDID string) []TierInfo {
150150- quotaTiers := m.quotaMgr.ListTiers()
151151- if len(quotaTiers) == 0 {
152152- return nil
153153- }
154154-155155- result := make([]TierInfo, 0, len(quotaTiers))
156156- for _, qt := range quotaTiers {
157157- var quotaBytes int64
158158- if qt.Limit != nil {
159159- quotaBytes = *qt.Limit
160160- }
161161-162162- // Capitalize tier ID for display name (e.g., "swabbie" -> "Swabbie")
163163- name := strings.ToUpper(qt.Key[:1]) + qt.Key[1:]
164164-165165- tier := TierInfo{
166166- ID: qt.Key,
167167- Name: name,
168168- QuotaBytes: quotaBytes,
169169- QuotaFormatted: quota.FormatHumanBytes(quotaBytes),
170170- }
171171-172172- // Merge billing metadata if available
173173- if bt := m.billingCfg.GetTierPricing(qt.Key); bt != nil {
174174- tier.Description = bt.Description
175175-176176- // Fetch actual prices from Stripe
177177- if bt.StripePriceMonthly != "" {
178178- if p, err := price.Get(bt.StripePriceMonthly, nil); err == nil && p != nil {
179179- tier.PriceCentsMonthly = int(p.UnitAmount)
180180- } else {
181181- slog.Debug("Failed to fetch monthly price", "priceId", bt.StripePriceMonthly, "error", err)
182182- tier.PriceCentsMonthly = -1
183183- }
184184- }
185185- if bt.StripePriceYearly != "" {
186186- if p, err := price.Get(bt.StripePriceYearly, nil); err == nil && p != nil {
187187- tier.PriceCentsYearly = int(p.UnitAmount)
188188- } else {
189189- slog.Debug("Failed to fetch yearly price", "priceId", bt.StripePriceYearly, "error", err)
190190- tier.PriceCentsYearly = -1
191191- }
192192- }
193193- }
194194-195195- result = append(result, tier)
196196- }
197197-198198- // Sort tiers by quota size (ascending)
199199- sort.Slice(result, func(i, j int) bool {
200200- return result[i].QuotaBytes < result[j].QuotaBytes
201201- })
202202-203203- return result
204204-}
205205-206206-// CreateCheckoutSession creates a Stripe checkout session for subscription.
207207-func (m *Manager) CreateCheckoutSession(r *http.Request, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
208208- if !m.Enabled() {
209209- return nil, ErrBillingDisabled
210210- }
211211-212212- // Get user DID from request context (set by auth middleware)
213213- userDID := r.Header.Get("X-User-DID")
214214- if userDID == "" {
215215- return nil, errors.New("user not authenticated")
216216- }
217217-218218- // Get tier config
219219- tierCfg := m.billingCfg.GetTierPricing(req.Tier)
220220- if tierCfg == nil {
221221- return nil, fmt.Errorf("tier not found: %s", req.Tier)
222222- }
223223-224224- // Determine price ID - prefer requested interval, fall back to what's available
225225- var priceID string
226226- switch req.Interval {
227227- case "monthly":
228228- priceID = tierCfg.StripePriceMonthly
229229- case "yearly":
230230- priceID = tierCfg.StripePriceYearly
231231- default:
232232- // No interval specified - prefer monthly, fall back to yearly
233233- if tierCfg.StripePriceMonthly != "" {
234234- priceID = tierCfg.StripePriceMonthly
235235- } else {
236236- priceID = tierCfg.StripePriceYearly
237237- }
238238- }
239239-240240- if priceID == "" {
241241- return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
242242- }
243243-244244- // Get or create customer
245245- cust, err := m.getOrCreateCustomer(userDID)
246246- if err != nil {
247247- return nil, fmt.Errorf("failed to get/create customer: %w", err)
248248- }
249249-250250- // Build success/cancel URLs
251251- successURL := strings.ReplaceAll(m.billingCfg.SuccessURL, "{hold_url}", m.holdPublicURL)
252252- cancelURL := strings.ReplaceAll(m.billingCfg.CancelURL, "{hold_url}", m.holdPublicURL)
253253-254254- if req.ReturnURL != "" {
255255- successURL = req.ReturnURL + "?success=true"
256256- cancelURL = req.ReturnURL + "?cancelled=true"
257257- }
258258-259259- // Create checkout session
260260- params := &stripe.CheckoutSessionParams{
261261- Customer: stripe.String(cust.ID),
262262- Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
263263- LineItems: []*stripe.CheckoutSessionLineItemParams{
264264- {
265265- Price: stripe.String(priceID),
266266- Quantity: stripe.Int64(1),
267267- },
268268- },
269269- SuccessURL: stripe.String(successURL),
270270- CancelURL: stripe.String(cancelURL),
271271- }
272272-273273- sess, err := session.New(params)
274274- if err != nil {
275275- return nil, fmt.Errorf("failed to create checkout session: %w", err)
276276- }
277277-278278- return &CheckoutSessionResponse{
279279- CheckoutURL: sess.URL,
280280- SessionID: sess.ID,
281281- }, nil
282282-}
283283-284284-// GetBillingPortalURL returns a URL to the Stripe billing portal.
285285-func (m *Manager) GetBillingPortalURL(userDID string, returnURL string) (*BillingPortalResponse, error) {
286286- if !m.Enabled() {
287287- return nil, ErrBillingDisabled
288288- }
289289-290290- // Find existing customer
291291- cust, err := m.findCustomerByDID(userDID)
292292- if err != nil || cust == nil {
293293- return nil, errors.New("no billing account found")
294294- }
295295-296296- if returnURL == "" {
297297- returnURL = m.holdPublicURL
298298- }
299299-300300- params := &stripe.BillingPortalSessionParams{
301301- Customer: stripe.String(cust.ID),
302302- ReturnURL: stripe.String(returnURL),
303303- }
304304-305305- sess, err := portalsession.New(params)
306306- if err != nil {
307307- return nil, fmt.Errorf("failed to create portal session: %w", err)
308308- }
309309-310310- return &BillingPortalResponse{
311311- PortalURL: sess.URL,
312312- }, nil
313313-}
314314-315315-// HandleWebhook processes a Stripe webhook event.
316316-func (m *Manager) HandleWebhook(r *http.Request) (*WebhookEvent, error) {
317317- if !m.Enabled() {
318318- return nil, ErrBillingDisabled
319319- }
320320-321321- body, err := io.ReadAll(r.Body)
322322- if err != nil {
323323- return nil, fmt.Errorf("failed to read request body: %w", err)
324324- }
325325-326326- // Verify webhook signature
327327- event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
328328- if err != nil {
329329- return nil, fmt.Errorf("failed to verify webhook signature: %w", err)
330330- }
331331-332332- result := &WebhookEvent{
333333- Type: string(event.Type),
334334- }
335335-336336- switch event.Type {
337337- case "checkout.session.completed":
338338- var sess stripe.CheckoutSession
339339- if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
340340- return nil, fmt.Errorf("failed to parse checkout session: %w", err)
341341- }
342342-343343- result.CustomerID = sess.Customer.ID
344344- result.SubscriptionID = sess.Subscription.ID
345345- result.Status = "active"
346346-347347- // Fetch customer to get DID from metadata
348348- result.UserDID = m.getCustomerDID(sess.Customer.ID)
349349-350350- // Get subscription to find the price/tier
351351- if sess.Subscription != nil && sess.Subscription.ID != "" {
352352- if sub, err := m.getSubscription(sess.Subscription.ID); err == nil && sub != nil {
353353- if len(sub.Items.Data) > 0 {
354354- result.PriceID = sub.Items.Data[0].Price.ID
355355- result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
356356- }
357357- }
358358- }
359359-360360- if result.UserDID != "" && result.NewTier != "" {
361361- slog.Info("Checkout completed",
362362- "userDid", result.UserDID,
363363- "tier", result.NewTier,
364364- "subscriptionId", result.SubscriptionID,
365365- )
366366- }
367367-368368- case "customer.subscription.created", "customer.subscription.updated":
369369- var sub stripe.Subscription
370370- if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
371371- return nil, fmt.Errorf("failed to parse subscription: %w", err)
372372- }
373373-374374- result.SubscriptionID = sub.ID
375375- result.CustomerID = sub.Customer.ID
376376- result.Status = string(sub.Status)
377377-378378- if len(sub.Items.Data) > 0 {
379379- result.PriceID = sub.Items.Data[0].Price.ID
380380- result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
381381- }
382382-383383- // Fetch customer to get DID from metadata (webhook doesn't include expanded customer)
384384- result.UserDID = m.getCustomerDID(sub.Customer.ID)
385385-386386- // If we have user DID and new tier, this signals that crew tier should be updated
387387- if result.UserDID != "" && result.NewTier != "" && sub.Status == stripe.SubscriptionStatusActive {
388388- slog.Info("Subscription activated",
389389- "userDid", result.UserDID,
390390- "tier", result.NewTier,
391391- "subscriptionId", result.SubscriptionID,
392392- )
393393- }
394394-395395- case "customer.subscription.deleted", "customer.subscription.paused":
396396- var sub stripe.Subscription
397397- if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
398398- return nil, fmt.Errorf("failed to parse subscription: %w", err)
399399- }
400400-401401- result.SubscriptionID = sub.ID
402402- result.CustomerID = sub.Customer.ID
403403- if event.Type == "customer.subscription.deleted" {
404404- result.Status = "cancelled"
405405- } else {
406406- result.Status = "paused"
407407- }
408408-409409- // Fetch customer to get DID from metadata
410410- result.UserDID = m.getCustomerDID(sub.Customer.ID)
411411-412412- // Set tier to default (downgrade on cancellation/pause)
413413- result.NewTier = m.quotaMgr.GetDefaultTier()
414414-415415- if result.UserDID != "" {
416416- slog.Info("Subscription inactive, downgrading to default tier",
417417- "userDid", result.UserDID,
418418- "tier", result.NewTier,
419419- "status", result.Status,
420420- )
421421- }
422422-423423- case "customer.subscription.resumed":
424424- var sub stripe.Subscription
425425- if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
426426- return nil, fmt.Errorf("failed to parse subscription: %w", err)
427427- }
428428-429429- result.SubscriptionID = sub.ID
430430- result.CustomerID = sub.Customer.ID
431431- result.Status = "active"
432432-433433- if len(sub.Items.Data) > 0 {
434434- result.PriceID = sub.Items.Data[0].Price.ID
435435- result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
436436- }
437437-438438- // Fetch customer to get DID from metadata
439439- result.UserDID = m.getCustomerDID(sub.Customer.ID)
440440-441441- if result.UserDID != "" && result.NewTier != "" {
442442- slog.Info("Subscription resumed, restoring tier",
443443- "userDid", result.UserDID,
444444- "tier", result.NewTier,
445445- )
446446- }
447447- }
448448-449449- return result, nil
450450-}
451451-452452-// getOrCreateCustomer finds or creates a Stripe customer for the given DID.
453453-func (m *Manager) getOrCreateCustomer(userDID string) (*stripe.Customer, error) {
454454- // Check cache first
455455- m.customerCacheMu.RLock()
456456- if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
457457- m.customerCacheMu.RUnlock()
458458- return cached.customer, nil
459459- }
460460- m.customerCacheMu.RUnlock()
461461-462462- // Try to find existing customer
463463- cust, err := m.findCustomerByDID(userDID)
464464- if err == nil && cust != nil {
465465- m.cacheCustomer(userDID, cust)
466466- return cust, nil
467467- }
468468-469469- // Create new customer
470470- params := &stripe.CustomerParams{
471471- Metadata: map[string]string{
472472- "user_did": userDID,
473473- "hold_did": m.holdPublicURL, // Not actually a DID but useful for tracking
474474- },
475475- }
476476-477477- cust, err = customer.New(params)
478478- if err != nil {
479479- return nil, fmt.Errorf("failed to create customer: %w", err)
480480- }
481481-482482- m.cacheCustomer(userDID, cust)
483483- return cust, nil
484484-}
485485-486486-// findCustomerByDID searches Stripe for a customer with the given DID in metadata.
487487-func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
488488- // Check cache first
489489- m.customerCacheMu.RLock()
490490- if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
491491- m.customerCacheMu.RUnlock()
492492- return cached.customer, nil
493493- }
494494- m.customerCacheMu.RUnlock()
495495-496496- // Search Stripe by metadata
497497- params := &stripe.CustomerSearchParams{
498498- SearchParams: stripe.SearchParams{
499499- Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
500500- },
501501- }
502502- params.AddExpand("data.subscriptions")
503503-504504- iter := customer.Search(params)
505505- if iter.Next() {
506506- cust := iter.Customer()
507507- m.cacheCustomer(userDID, cust)
508508- return cust, nil
509509- }
510510-511511- if err := iter.Err(); err != nil {
512512- return nil, err
513513- }
514514-515515- return nil, nil // Not found
516516-}
517517-518518-// cacheCustomer adds a customer to the in-memory cache.
519519-func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
520520- m.customerCacheMu.Lock()
521521- defer m.customerCacheMu.Unlock()
522522-523523- m.customerCache[userDID] = &cachedCustomer{
524524- customer: cust,
525525- expiresAt: time.Now().Add(customerCacheTTL),
526526- }
527527-}
528528-529529-// InvalidateCustomerCache removes a customer from the cache.
530530-func (m *Manager) InvalidateCustomerCache(userDID string) {
531531- m.customerCacheMu.Lock()
532532- defer m.customerCacheMu.Unlock()
533533-534534- delete(m.customerCache, userDID)
535535-}
536536-537537-// getCustomerDID fetches a customer by ID and returns the user_did from metadata.
538538-func (m *Manager) getCustomerDID(customerID string) string {
539539- if customerID == "" {
540540- return ""
541541- }
542542-543543- cust, err := customer.Get(customerID, nil)
544544- if err != nil {
545545- slog.Debug("Failed to fetch customer", "customerId", customerID, "error", err)
546546- return ""
547547- }
548548-549549- if cust.Metadata != nil {
550550- return cust.Metadata["user_did"]
551551- }
552552- return ""
553553-}
554554-555555-// getSubscription fetches a subscription by ID.
556556-func (m *Manager) getSubscription(subscriptionID string) (*stripe.Subscription, error) {
557557- if subscriptionID == "" {
558558- return nil, nil
559559- }
560560-561561- params := &stripe.SubscriptionParams{}
562562- params.AddExpand("items.data.price")
563563-564564- return subscription.Get(subscriptionID, params)
565565-}
-60
pkg/hold/billing/billing_stub.go
···11-//go:build !billing
22-33-package billing
44-55-import (
66- "net/http"
77-88- "github.com/go-chi/chi/v5"
99-1010- "atcr.io/pkg/hold/pds"
1111- "atcr.io/pkg/hold/quota"
1212-)
1313-1414-// Manager is a no-op billing manager when billing is not compiled in.
1515-type Manager struct{}
1616-1717-// New creates a new no-op billing manager.
1818-// This is used when the billing build tag is not set.
1919-func New(_ *quota.Manager, _ string, _ string) *Manager {
2020- return &Manager{}
2121-}
2222-2323-// Enabled returns false when billing is not compiled in.
2424-func (m *Manager) Enabled() bool {
2525- return false
2626-}
2727-2828-// RegisterHandlers is a no-op when billing is not compiled in.
2929-func (m *Manager) RegisterHandlers(_ chi.Router) {}
3030-3131-// GetSubscriptionInfo returns an error when billing is not compiled in.
3232-func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
3333- return nil, ErrBillingDisabled
3434-}
3535-3636-// CreateCheckoutSession returns an error when billing is not compiled in.
3737-func (m *Manager) CreateCheckoutSession(_ *http.Request, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
3838- return nil, ErrBillingDisabled
3939-}
4040-4141-// GetBillingPortalURL returns an error when billing is not compiled in.
4242-func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
4343- return nil, ErrBillingDisabled
4444-}
4545-4646-// HandleWebhook returns an error when billing is not compiled in.
4747-func (m *Manager) HandleWebhook(_ *http.Request) (*WebhookEvent, error) {
4848- return nil, ErrBillingDisabled
4949-}
5050-5151-// XRPCHandler is a no-op handler when billing is not compiled in.
5252-type XRPCHandler struct{}
5353-5454-// NewXRPCHandler creates a new no-op XRPC handler.
5555-func NewXRPCHandler(_ *Manager, _ *pds.HoldPDS, _ *http.Client) *XRPCHandler {
5656- return &XRPCHandler{}
5757-}
5858-5959-// RegisterHandlers is a no-op when billing is not compiled in.
6060-func (h *XRPCHandler) RegisterHandlers(_ chi.Router) {}
-132
pkg/hold/billing/config.go
···11-//go:build billing
22-33-package billing
44-55-import (
66- "fmt"
77- "os"
88-99- "go.yaml.in/yaml/v4"
1010-)
1111-1212-// BillingConfig holds billing/Stripe settings parsed from the hold config YAML.
1313-// The billing section is a top-level key in the YAML file, separate from quota.
1414-type BillingConfig struct {
1515- Enabled bool
1616- Currency string
1717- SuccessURL string
1818- CancelURL string
1919-2020- // Tier-level billing info keyed by tier name (same keys as quota tiers).
2121- Tiers map[string]BillingTierConfig
2222-2323- // Tier assigned to plankowner crew members.
2424- PlankOwnerCrewTier string
2525-}
2626-2727-// BillingTierConfig holds Stripe pricing for a single tier.
2828-type BillingTierConfig struct {
2929- Description string `yaml:"description,omitempty"`
3030- StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"`
3131- StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"`
3232-}
3333-3434-// billingYAML is the top-level YAML structure for extracting the billing section.
3535-type billingYAML struct {
3636- Billing rawBillingConfig `yaml:"billing"`
3737-}
3838-3939-type rawBillingConfig struct {
4040- Enabled bool `yaml:"enabled"`
4141- Currency string `yaml:"currency,omitempty"`
4242- SuccessURL string `yaml:"success_url,omitempty"`
4343- CancelURL string `yaml:"cancel_url,omitempty"`
4444- PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"`
4545- Tiers map[string]BillingTierConfig `yaml:"tiers,omitempty"`
4646-}
4747-4848-// LoadBillingConfig reads the hold config YAML and extracts billing fields.
4949-// Returns (nil, nil) if the file is missing or billing is not enabled.
5050-// Returns (nil, err) if the file exists with billing enabled but is misconfigured.
5151-func LoadBillingConfig(configPath string) (*BillingConfig, error) {
5252- if configPath == "" {
5353- return nil, nil
5454- }
5555-5656- data, err := os.ReadFile(configPath)
5757- if err != nil {
5858- if os.IsNotExist(err) {
5959- return nil, nil
6060- }
6161- return nil, fmt.Errorf("failed to read config: %w", err)
6262- }
6363-6464- return parseBillingConfig(data)
6565-}
6666-6767-// parseBillingConfig extracts billing fields from hold config YAML bytes.
6868-// Returns (nil, nil) if billing is not enabled.
6969-// Returns (nil, err) if billing is enabled but misconfigured.
7070-func parseBillingConfig(data []byte) (*BillingConfig, error) {
7171- var raw billingYAML
7272- if err := yaml.Unmarshal(data, &raw); err != nil {
7373- return nil, fmt.Errorf("failed to parse config: %w", err)
7474- }
7575-7676- if !raw.Billing.Enabled {
7777- return nil, nil
7878- }
7979-8080- cfg := &BillingConfig{
8181- Enabled: true,
8282- Currency: raw.Billing.Currency,
8383- SuccessURL: raw.Billing.SuccessURL,
8484- CancelURL: raw.Billing.CancelURL,
8585- PlankOwnerCrewTier: raw.Billing.PlankOwnerCrewTier,
8686- Tiers: raw.Billing.Tiers,
8787- }
8888-8989- if cfg.Tiers == nil {
9090- cfg.Tiers = make(map[string]BillingTierConfig)
9191- }
9292-9393- // Validate: billing enabled but no tiers have any Stripe prices configured
9494- hasAnyPrice := false
9595- for _, tier := range cfg.Tiers {
9696- if tier.StripePriceMonthly != "" || tier.StripePriceYearly != "" {
9797- hasAnyPrice = true
9898- break
9999- }
100100- }
101101- if !hasAnyPrice {
102102- return nil, fmt.Errorf("billing is enabled but no tiers have Stripe prices configured")
103103- }
104104-105105- return cfg, nil
106106-}
107107-108108-// GetTierPricing returns billing info for a tier, or nil if not found.
109109-func (c *BillingConfig) GetTierPricing(tierKey string) *BillingTierConfig {
110110- if c == nil {
111111- return nil
112112- }
113113- t, ok := c.Tiers[tierKey]
114114- if !ok {
115115- return nil
116116- }
117117- return &t
118118-}
119119-120120-// GetTierByPriceID finds the tier key that contains the given Stripe price ID.
121121-// Returns empty string if no match.
122122-func (c *BillingConfig) GetTierByPriceID(priceID string) string {
123123- if c == nil || priceID == "" {
124124- return ""
125125- }
126126- for key, tier := range c.Tiers {
127127- if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID {
128128- return key
129129- }
130130- }
131131- return ""
132132-}
···11-//go:build billing
22-33-package billing
44-55-import (
66- "encoding/json"
77- "log/slog"
88- "net/http"
99-1010- "github.com/go-chi/chi/v5"
1111-1212- "atcr.io/pkg/hold/pds"
1313-)
1414-1515-// XRPCHandler handles billing-related XRPC endpoints.
1616-type XRPCHandler struct {
1717- manager *Manager
1818- pdsServer *pds.HoldPDS
1919- httpClient *http.Client
2020-}
2121-2222-// NewXRPCHandler creates a new billing XRPC handler.
2323-func NewXRPCHandler(manager *Manager, pdsServer *pds.HoldPDS, httpClient *http.Client) *XRPCHandler {
2424- return &XRPCHandler{
2525- manager: manager,
2626- pdsServer: pdsServer,
2727- httpClient: httpClient,
2828- }
2929-}
3030-3131-// RegisterHandlers registers billing XRPC endpoints on the router.
3232-func (m *Manager) RegisterHandlers(r chi.Router) {
3333- // This is a no-op for the Manager itself
3434- // Use NewXRPCHandler and call its RegisterHandlers method
3535-}
3636-3737-// RegisterHandlers registers billing endpoints on the router.
3838-func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
3939- if !h.manager.Enabled() {
4040- slog.Info("Billing endpoints disabled (not configured)")
4141- return
4242- }
4343-4444- slog.Info("Registering billing XRPC endpoints")
4545-4646- // Public endpoint - get subscription info (auth optional for tiers list)
4747- r.Get("/xrpc/io.atcr.hold.getSubscriptionInfo", h.HandleGetSubscriptionInfo)
4848-4949- // Authenticated endpoints
5050- r.Group(func(r chi.Router) {
5151- r.Use(h.requireAuth)
5252- r.Post("/xrpc/io.atcr.hold.createCheckoutSession", h.HandleCreateCheckoutSession)
5353- r.Get("/xrpc/io.atcr.hold.getBillingPortalUrl", h.HandleGetBillingPortalURL)
5454- })
5555-5656- // Stripe webhook (authenticated by Stripe signature)
5757- r.Post("/xrpc/io.atcr.hold.stripeWebhook", h.HandleStripeWebhook)
5858-}
5959-6060-// requireAuth is middleware that validates user authentication.
6161-func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler {
6262- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6363- // Use the same auth validation as other hold endpoints
6464- user, err := pds.ValidateDPoPRequest(r, h.httpClient)
6565- if err != nil {
6666- // Try service token
6767- user, err = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
6868- }
6969- if err != nil {
7070- respondError(w, http.StatusUnauthorized, "authentication required")
7171- return
7272- }
7373-7474- // Store user DID in header for handlers
7575- r.Header.Set("X-User-DID", user.DID)
7676- next.ServeHTTP(w, r)
7777- })
7878-}
7979-8080-// HandleGetSubscriptionInfo returns subscription and quota information.
8181-// GET /xrpc/io.atcr.hold.getSubscriptionInfo?userDid=did:plc:xxx
8282-func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.Request) {
8383- userDID := r.URL.Query().Get("userDid")
8484-8585- // If no userDID provided, try to get from auth
8686- if userDID == "" {
8787- // Try to authenticate (optional)
8888- user, err := pds.ValidateDPoPRequest(r, h.httpClient)
8989- if err != nil {
9090- user, _ = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
9191- }
9292- if user != nil {
9393- userDID = user.DID
9494- }
9595- }
9696-9797- info, err := h.manager.GetSubscriptionInfo(userDID)
9898- if err != nil {
9999- if err == ErrBillingDisabled {
100100- // Return basic info with payments disabled
101101- respondJSON(w, http.StatusOK, &SubscriptionInfo{
102102- UserDID: userDID,
103103- PaymentsEnabled: false,
104104- Tiers: h.manager.buildTierList(userDID),
105105- })
106106- return
107107- }
108108- respondError(w, http.StatusInternalServerError, err.Error())
109109- return
110110- }
111111-112112- // Get current usage and crew tier from PDS quota stats
113113- if userDID != "" {
114114- stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr)
115115- if err == nil {
116116- info.CurrentUsage = stats.TotalSize
117117- info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced)
118118- info.CurrentLimit = stats.Limit
119119-120120- // If no subscription but crew has a tier, show that as current
121121- if info.SubscriptionID == "" && info.CrewTier != "" {
122122- info.CurrentTier = info.CrewTier
123123- }
124124- }
125125- }
126126-127127- // Mark which tier is actually current (use crew tier if available, otherwise subscription tier)
128128- effectiveTier := info.CurrentTier
129129- if info.CrewTier != "" {
130130- effectiveTier = info.CrewTier
131131- }
132132- for i := range info.Tiers {
133133- info.Tiers[i].IsCurrent = info.Tiers[i].ID == effectiveTier
134134- }
135135-136136- respondJSON(w, http.StatusOK, info)
137137-}
138138-139139-// HandleCreateCheckoutSession creates a Stripe checkout session.
140140-// POST /xrpc/io.atcr.hold.createCheckoutSession
141141-func (h *XRPCHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
142142- var req CheckoutSessionRequest
143143- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
144144- respondError(w, http.StatusBadRequest, "invalid request body")
145145- return
146146- }
147147-148148- if req.Tier == "" {
149149- respondError(w, http.StatusBadRequest, "tier is required")
150150- return
151151- }
152152-153153- resp, err := h.manager.CreateCheckoutSession(r, &req)
154154- if err != nil {
155155- slog.Error("Failed to create checkout session", "error", err)
156156- respondError(w, http.StatusInternalServerError, err.Error())
157157- return
158158- }
159159-160160- respondJSON(w, http.StatusOK, resp)
161161-}
162162-163163-// HandleGetBillingPortalURL returns a URL to the Stripe billing portal.
164164-// GET /xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=https://...
165165-func (h *XRPCHandler) HandleGetBillingPortalURL(w http.ResponseWriter, r *http.Request) {
166166- userDID := r.Header.Get("X-User-DID")
167167- returnURL := r.URL.Query().Get("returnUrl")
168168-169169- resp, err := h.manager.GetBillingPortalURL(userDID, returnURL)
170170- if err != nil {
171171- slog.Error("Failed to get billing portal URL", "error", err, "userDid", userDID)
172172- respondError(w, http.StatusInternalServerError, err.Error())
173173- return
174174- }
175175-176176- respondJSON(w, http.StatusOK, resp)
177177-}
178178-179179-// HandleStripeWebhook processes Stripe webhook events.
180180-// POST /xrpc/io.atcr.hold.stripeWebhook
181181-func (h *XRPCHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
182182- event, err := h.manager.HandleWebhook(r)
183183- if err != nil {
184184- slog.Error("Failed to process webhook", "error", err)
185185- respondError(w, http.StatusBadRequest, err.Error())
186186- return
187187- }
188188-189189- // If we have a tier update, apply it to the crew record
190190- if event.UserDID != "" && event.NewTier != "" {
191191- if err := h.pdsServer.UpdateCrewMemberTier(r.Context(), event.UserDID, event.NewTier); err != nil {
192192- slog.Error("Failed to update crew tier", "error", err, "userDid", event.UserDID, "tier", event.NewTier)
193193- // Don't fail the webhook - Stripe will retry
194194- } else {
195195- slog.Info("Updated crew tier from subscription",
196196- "userDid", event.UserDID,
197197- "tier", event.NewTier,
198198- "event", event.Type,
199199- )
200200- }
201201-202202- // Invalidate customer cache since subscription changed
203203- h.manager.InvalidateCustomerCache(event.UserDID)
204204- }
205205-206206- // Return 200 to acknowledge receipt
207207- respondJSON(w, http.StatusOK, map[string]string{"received": "true"})
208208-}
209209-210210-// respondJSON writes a JSON response.
211211-func respondJSON(w http.ResponseWriter, status int, v any) {
212212- w.Header().Set("Content-Type", "application/json")
213213- w.WriteHeader(status)
214214- if err := json.NewEncoder(w).Encode(v); err != nil {
215215- slog.Error("Failed to encode JSON response", "error", err)
216216- }
217217-}
218218-219219-// respondError writes a JSON error response.
220220-func respondError(w http.ResponseWriter, status int, message string) {
221221- respondJSON(w, status, map[string]string{"error": message})
222222-}
-65
pkg/hold/billing/types.go
···11-// Package billing provides optional Stripe billing integration for hold services.
22-// This package uses build tags to conditionally compile Stripe support.
33-// Build with -tags billing to enable Stripe integration.
44-package billing
55-66-import "errors"
77-88-// ErrBillingDisabled is returned when billing operations are attempted
99-// but billing is not enabled (either not compiled in or disabled at runtime).
1010-var ErrBillingDisabled = errors.New("billing not enabled")
1111-1212-// SubscriptionInfo contains subscription and quota information for a user.
1313-type SubscriptionInfo struct {
1414- UserDID string `json:"userDid"`
1515- CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default)
1616- CrewTier string `json:"crewTier,omitempty"` // tier from local crew record (what's actually enforced)
1717- CurrentUsage int64 `json:"currentUsage"` // bytes used
1818- CurrentLimit *int64 `json:"currentLimit,omitempty"` // nil = unlimited
1919- PaymentsEnabled bool `json:"paymentsEnabled"` // whether online payments are available
2020- Tiers []TierInfo `json:"tiers"` // available tiers
2121- SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active
2222- CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists
2323- BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly"
2424-}
2525-2626-// TierInfo describes a single tier available for subscription.
2727-type TierInfo struct {
2828- ID string `json:"id"` // tier key (e.g., "deckhand", "bosun")
2929- Name string `json:"name"` // display name (same as ID if not specified)
3030- Description string `json:"description,omitempty"` // human-readable description
3131- QuotaBytes int64 `json:"quotaBytes"` // quota limit in bytes
3232- QuotaFormatted string `json:"quotaFormatted"` // human-readable quota (e.g., "5 GB")
3333- PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free)
3434- PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available)
3535- IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier
3636-}
3737-3838-// CheckoutSessionRequest is the request to create a Stripe checkout session.
3939-type CheckoutSessionRequest struct {
4040- Tier string `json:"tier"` // tier to subscribe to
4141- Interval string `json:"interval,omitempty"` // "monthly" or "yearly" (default: monthly)
4242- ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout
4343-}
4444-4545-// CheckoutSessionResponse is the response with the Stripe checkout URL.
4646-type CheckoutSessionResponse struct {
4747- CheckoutURL string `json:"checkoutUrl"`
4848- SessionID string `json:"sessionId"`
4949-}
5050-5151-// BillingPortalResponse is the response with the Stripe billing portal URL.
5252-type BillingPortalResponse struct {
5353- PortalURL string `json:"portalUrl"`
5454-}
5555-5656-// WebhookEvent represents a processed Stripe webhook event.
5757-type WebhookEvent struct {
5858- Type string `json:"type"` // e.g., "customer.subscription.updated"
5959- CustomerID string `json:"customerId"` // Stripe customer ID
6060- UserDID string `json:"userDid"` // user's DID from customer metadata
6161- SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID
6262- PriceID string `json:"priceId,omitempty"` // Stripe price ID
6363- NewTier string `json:"newTier,omitempty"` // resolved tier name
6464- Status string `json:"status,omitempty"` // subscription status
6565-}
+29-7
pkg/hold/config.go
···1010 "fmt"
1111 "log/slog"
1212 "path/filepath"
1313+ "strings"
1314 "time"
14151516 "github.com/spf13/viper"
···1819 "atcr.io/pkg/hold/gc"
1920 "atcr.io/pkg/hold/quota"
2021)
2222+2323+// URLFromDIDWeb converts a did:web identifier to an HTTPS URL.
2424+// This is the inverse of the did:web spec encoding:
2525+//
2626+// "did:web:atcr.io" → "https://atcr.io"
2727+// "did:web:localhost%3A8080" → "https://localhost:8080"
2828+//
2929+// Returns empty string for non-did:web identifiers.
3030+func URLFromDIDWeb(did string) string {
3131+ if !strings.HasPrefix(did, "did:web:") {
3232+ return ""
3333+ }
3434+ host := strings.TrimPrefix(did, "did:web:")
3535+ // Per did:web spec, %3A encodes port colon
3636+ host = strings.ReplaceAll(host, "%3A", ":")
3737+ return "https://" + host
3838+}
21392240// Config represents the hold service configuration
2341type Config struct {
···128146 // Request crawl from this relay on startup.
129147 RelayEndpoint string `yaml:"relay_endpoint" comment:"Request crawl from this relay on startup to make the embedded PDS discoverable."`
130148131131- // Preferred appview URL for links in webhooks and Bluesky posts.
132132- AppviewURL string `yaml:"appview_url" comment:"Preferred appview URL for links in webhooks and Bluesky posts, e.g. \"https://seamark.dev\"."`
149149+ // DID of the appview this hold is managed by. Resolved via did:web for URL and public key discovery.
150150+ 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."`
133151134152 // ReadTimeout for HTTP requests.
135153 ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."`
136154137155 // WriteTimeout for HTTP requests.
138156 WriteTimeout time.Duration `yaml:"write_timeout" comment:"Write timeout for HTTP requests."`
157157+}
158158+159159+// AppviewURL derives the appview base URL from AppviewDID.
160160+func (s ServerConfig) AppviewURL() string {
161161+ return URLFromDIDWeb(s.AppviewDID)
139162}
140163141164// ScannerConfig defines vulnerability scanner settings
···189212 v.SetDefault("server.successor", "")
190213 v.SetDefault("server.test_mode", false)
191214 v.SetDefault("server.relay_endpoint", "")
192192- v.SetDefault("server.appview_url", "https://atcr.io")
215215+ v.SetDefault("server.appview_did", "did:web:atcr.io")
193216 v.SetDefault("server.read_timeout", "5m")
194217 v.SetDefault("server.write_timeout", "5m")
195218···252275 // Populate example quota tiers so operators see the structure
253276 cfg.Quota = quota.Config{
254277 Tiers: []quota.TierConfig{
255255- {Name: "deckhand", Quota: "5GB", MaxWebhooks: 1},
256256- {Name: "bosun", Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true},
257257- {Name: "quartermaster", Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true},
278278+ {Name: "deckhand", Quota: "5GB"},
279279+ {Name: "bosun", Quota: "50GB", ScanOnPush: true},
280280+ {Name: "quartermaster", Quota: "100GB", ScanOnPush: true},
258281 },
259282 Defaults: quota.DefaultsConfig{
260283 NewCrewTier: "deckhand",
261261- OwnerBadge: true,
262284 },
263285 }
264286
+152
pkg/hold/pds/auth.go
···529529 }, nil
530530}
531531532532+// ValidateAppviewToken validates a JWT signed by the trusted appview using ES256 (P-256).
533533+// It resolves the appview's DID document to extract the P-256 public key, then verifies
534534+// the JWT signature, issuer (iss), and audience (aud).
535535+//
536536+// Returns the subject (sub) claim which is the user DID being acted upon.
537537+func ValidateAppviewToken(r *http.Request, appviewDID, holdDID string) (string, error) {
538538+ // Extract Authorization header
539539+ authHeader := r.Header.Get("Authorization")
540540+ if authHeader == "" {
541541+ return "", ErrMissingAuthHeader
542542+ }
543543+544544+ parts := strings.SplitN(authHeader, " ", 2)
545545+ if len(parts) != 2 || parts[0] != "Bearer" {
546546+ return "", fmt.Errorf("expected Bearer authorization scheme")
547547+ }
548548+549549+ tokenString := parts[1]
550550+ if tokenString == "" {
551551+ return "", ErrMissingToken
552552+ }
553553+554554+ // Manually parse JWT
555555+ tokenParts := strings.Split(tokenString, ".")
556556+ if len(tokenParts) != 3 {
557557+ return "", ErrInvalidJWTFormat
558558+ }
559559+560560+ // Decode and parse claims
561561+ payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
562562+ if err != nil {
563563+ return "", fmt.Errorf("failed to decode JWT payload: %w", err)
564564+ }
565565+566566+ var claims ServiceTokenClaims
567567+ if err := json.Unmarshal(payloadBytes, &claims); err != nil {
568568+ return "", fmt.Errorf("failed to unmarshal claims: %w", err)
569569+ }
570570+571571+ // Verify issuer matches configured appview DID
572572+ if claims.Issuer != appviewDID {
573573+ return "", fmt.Errorf("token issuer mismatch: expected %s, got %s", appviewDID, claims.Issuer)
574574+ }
575575+576576+ // Verify audience matches this hold's DID
577577+ audiences, err := claims.GetAudience()
578578+ if err != nil {
579579+ return "", fmt.Errorf("failed to get audience: %w", err)
580580+ }
581581+ if len(audiences) == 0 || audiences[0] != holdDID {
582582+ return "", fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences)
583583+ }
584584+585585+ // Verify expiration
586586+ exp, err := claims.GetExpirationTime()
587587+ if err != nil {
588588+ return "", fmt.Errorf("failed to get expiration: %w", err)
589589+ }
590590+ if exp != nil && time.Now().After(exp.Time) {
591591+ return "", ErrTokenExpired
592592+ }
593593+594594+ // Get subject (user DID)
595595+ subject, err := claims.GetSubject()
596596+ if err != nil || subject == "" {
597597+ return "", ErrMissingSubClaim
598598+ }
599599+600600+ // Fetch P-256 public key from appview DID document
601601+ pubKey, err := fetchP256PublicKeyFromDID(r.Context(), appviewDID)
602602+ if err != nil {
603603+ return "", fmt.Errorf("failed to fetch appview public key: %w", err)
604604+ }
605605+606606+ // Verify JWT signature with P-256 key
607607+ signedData := []byte(tokenParts[0] + "." + tokenParts[1])
608608+ signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
609609+ if err != nil {
610610+ return "", fmt.Errorf("failed to decode signature: %w", err)
611611+ }
612612+613613+ if err := pubKey.HashAndVerifyLenient(signedData, signature); err != nil {
614614+ return "", fmt.Errorf("signature verification failed: %w", err)
615615+ }
616616+617617+ slog.Debug("Validated appview service token", "appviewDID", appviewDID, "userDID", subject)
618618+ return subject, nil
619619+}
620620+621621+// fetchP256PublicKeyFromDID fetches a P-256 public key from a did:web DID document.
622622+// It resolves the DID document and looks for a Multikey verification method with P-256 prefix.
623623+func fetchP256PublicKeyFromDID(ctx context.Context, did string) (*atcrypto.PublicKeyP256, error) {
624624+ if !strings.HasPrefix(did, "did:web:") {
625625+ return nil, fmt.Errorf("only did:web is supported for appview DID, got %s", did)
626626+ }
627627+628628+ // Resolve did:web to URL
629629+ host := strings.TrimPrefix(did, "did:web:")
630630+ host = strings.ReplaceAll(host, "%3A", ":")
631631+ scheme := "https"
632632+ if atproto.IsTestMode() {
633633+ scheme = "http"
634634+ }
635635+ didDocURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, host)
636636+637637+ req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil)
638638+ if err != nil {
639639+ return nil, fmt.Errorf("failed to create request: %w", err)
640640+ }
641641+642642+ resp, err := http.DefaultClient.Do(req)
643643+ if err != nil {
644644+ return nil, fmt.Errorf("failed to fetch DID document: %w", err)
645645+ }
646646+ defer resp.Body.Close()
647647+648648+ if resp.StatusCode != http.StatusOK {
649649+ return nil, fmt.Errorf("DID document fetch returned status %d", resp.StatusCode)
650650+ }
651651+652652+ var doc struct {
653653+ VerificationMethod []struct {
654654+ ID string `json:"id"`
655655+ Type string `json:"type"`
656656+ PublicKeyMultibase string `json:"publicKeyMultibase"`
657657+ } `json:"verificationMethod"`
658658+ }
659659+ if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
660660+ return nil, fmt.Errorf("failed to decode DID document: %w", err)
661661+ }
662662+663663+ // Find a Multikey verification method with P-256 public key
664664+ for _, vm := range doc.VerificationMethod {
665665+ if vm.Type != "Multikey" || vm.PublicKeyMultibase == "" {
666666+ continue
667667+ }
668668+669669+ // Try parsing as P-256 key via atcrypto's multibase parser
670670+ pubKey, err := atcrypto.ParsePublicMultibase(vm.PublicKeyMultibase)
671671+ if err != nil {
672672+ continue
673673+ }
674674+675675+ p256Key, ok := pubKey.(*atcrypto.PublicKeyP256)
676676+ if ok {
677677+ return p256Key, nil
678678+ }
679679+ }
680680+681681+ return nil, fmt.Errorf("no P-256 public key found in DID document for %s", did)
682682+}
683683+532684// fetchPublicKeyFromDID fetches the public key from a DID document
533685// Supports did:plc and did:web
534686// Returns the atcrypto.PublicKey for signature verification
-18
pkg/hold/pds/scan_broadcaster.go
···145145 db.Close()
146146 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
147147 }
148148- if err := sb.initWebhookSchema(); err != nil {
149149- db.Close()
150150- return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
151151- }
152152-153148 // Start re-dispatch loop for timed-out jobs
154149 sb.wg.Add(1)
155150 go sb.reDispatchLoop()
···191186 if err := sb.initSchema(); err != nil {
192187 return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
193188 }
194194- if err := sb.initWebhookSchema(); err != nil {
195195- return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
196196- }
197197-198189 sb.wg.Add(1)
199190 go sb.reDispatchLoop()
200191···517508518509 // Store scan result as a record in the hold's embedded PDS
519510 if msg.Summary != nil {
520520- // Check for existing scan record before creating new one (for webhook dispatch)
521521- var previousScan *atproto.ScanRecord
522522- _, prevScan, err := sb.pds.GetScanRecord(ctx, manifestDigest)
523523- if err == nil {
524524- previousScan = prevScan
525525- }
526526-527511 scanRecord := atproto.NewScanRecord(
528512 manifestDigest, repository, userDID,
529513 sbomBlob, vulnReportBlob,
···545529 "total", msg.Summary.Total)
546530 }
547531548548- // Dispatch webhooks after scan record is stored
549549- go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle, msg.Summary, previousScan)
550532 }
551533552534 // Mark job as completed
+1-2
pkg/hold/pds/server.go
···3232 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
3333 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
3434 lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
3535- lexutil.RegisterType(atproto.WebhookCollection, &atproto.HoldWebhookRecord{})
3635}
37363837// HoldPDS is a minimal ATProto PDS implementation for a hold service
···5049 recordsIndex *RecordsIndex
5150}
52515353-// AppviewURL returns the configured appview base URL for links in webhooks and posts.
5252+// AppviewURL returns the configured appview base URL for links in Bluesky posts.
5453func (p *HoldPDS) AppviewURL() string { return p.appviewURL }
55545655// AppviewMeta returns cached appview metadata, or defaults derived from the appview URL.
-831
pkg/hold/pds/webhooks.go
···11-package pds
22-33-import (
44- "context"
55- "crypto/hmac"
66- "crypto/sha256"
77- "encoding/hex"
88- "encoding/json"
99- "fmt"
1010- "io"
1111- "log/slog"
1212- "math/rand/v2"
1313- "net/http"
1414- "net/url"
1515- "strings"
1616- "time"
1717-1818- "atcr.io/pkg/atproto"
1919- "github.com/go-chi/chi/v5"
2020- "github.com/go-chi/render"
2121- "github.com/ipfs/go-cid"
2222-)
2323-2424-// webhookConfig represents a webhook for list/display (masked URL, no secret)
2525-type webhookConfig struct {
2626- Rkey string `json:"rkey"`
2727- Triggers int `json:"triggers"`
2828- URL string `json:"url"` // masked
2929- HasSecret bool `json:"hasSecret"`
3030- CreatedAt string `json:"createdAt"`
3131-}
3232-3333-// activeWebhook is the internal representation with secret for dispatch
3434-type activeWebhook struct {
3535- Rkey string
3636- URL string
3737- Secret string
3838- Triggers int
3939-}
4040-4141-// WebhookPayload is the JSON body sent to webhook URLs
4242-type WebhookPayload struct {
4343- Trigger string `json:"trigger"`
4444- HoldDID string `json:"holdDid"`
4545- HoldEndpoint string `json:"holdEndpoint"`
4646- Manifest WebhookManifestInfo `json:"manifest"`
4747- Scan WebhookScanInfo `json:"scan"`
4848- Previous *WebhookVulnCounts `json:"previous"`
4949-}
5050-5151-// WebhookManifestInfo describes the scanned manifest
5252-type WebhookManifestInfo struct {
5353- Digest string `json:"digest"`
5454- Repository string `json:"repository"`
5555- Tag string `json:"tag"`
5656- UserDID string `json:"userDid"`
5757- UserHandle string `json:"userHandle,omitempty"`
5858-}
5959-6060-// WebhookScanInfo describes the scan results
6161-type WebhookScanInfo struct {
6262- ScannedAt string `json:"scannedAt"`
6363- ScannerVersion string `json:"scannerVersion"`
6464- Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"`
6565-}
6666-6767-// WebhookVulnCounts contains vulnerability counts by severity
6868-type WebhookVulnCounts struct {
6969- Critical int `json:"critical"`
7070- High int `json:"high"`
7171- Medium int `json:"medium"`
7272- Low int `json:"low"`
7373- Total int `json:"total"`
7474-}
7575-7676-// initWebhookSchema creates the webhook_secrets table.
7777-// Called from ScanBroadcaster init alongside scan_jobs table.
7878-func (sb *ScanBroadcaster) initWebhookSchema() error {
7979- stmts := []string{
8080- `CREATE TABLE IF NOT EXISTS webhook_secrets (
8181- rkey TEXT PRIMARY KEY,
8282- user_did TEXT NOT NULL,
8383- url TEXT NOT NULL,
8484- secret TEXT
8585- )`,
8686- `CREATE INDEX IF NOT EXISTS idx_webhook_secrets_user ON webhook_secrets(user_did)`,
8787- }
8888- for _, stmt := range stmts {
8989- if _, err := sb.db.Exec(stmt); err != nil {
9090- return fmt.Errorf("failed to create webhook_secrets table: %w", err)
9191- }
9292- }
9393- return nil
9494-}
9595-9696-// CountWebhooks returns the number of webhooks configured for a user
9797-func (sb *ScanBroadcaster) CountWebhooks(userDID string) (int, error) {
9898- var count int
9999- err := sb.db.QueryRow(`SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?`, userDID).Scan(&count)
100100- return count, err
101101-}
102102-103103-// ListWebhookConfigs returns webhook configurations for display (masked URLs)
104104-func (sb *ScanBroadcaster) ListWebhookConfigs(userDID string) ([]webhookConfig, error) {
105105- rows, err := sb.db.Query(`
106106- SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
107107- `, userDID)
108108- if err != nil {
109109- return nil, err
110110- }
111111- defer rows.Close()
112112-113113- var configs []webhookConfig
114114- for rows.Next() {
115115- var rkey, rawURL, secret string
116116- if err := rows.Scan(&rkey, &rawURL, &secret); err != nil {
117117- continue
118118- }
119119-120120- // Get triggers from PDS record
121121- triggers := 0
122122- _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, rkey, cid.Undef)
123123- if err == nil {
124124- if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
125125- triggers = int(rec.Triggers)
126126- }
127127- }
128128-129129- // Get createdAt from PDS record
130130- createdAt := ""
131131- if val != nil {
132132- if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
133133- createdAt = rec.CreatedAt
134134- }
135135- }
136136-137137- configs = append(configs, webhookConfig{
138138- Rkey: rkey,
139139- Triggers: triggers,
140140- URL: maskURL(rawURL),
141141- HasSecret: secret != "",
142142- CreatedAt: createdAt,
143143- })
144144- }
145145- if configs == nil {
146146- configs = []webhookConfig{}
147147- }
148148- return configs, nil
149149-}
150150-151151-// AddWebhookConfig creates a new webhook: stores secret in SQLite, record in PDS
152152-func (sb *ScanBroadcaster) AddWebhookConfig(userDID, webhookURL, secret string, triggers int) (string, cid.Cid, error) {
153153- ctx := context.Background()
154154-155155- // Use TID for rkey — avoids collisions after delete+re-add
156156- rkey := sb.pds.repomgr.NextTID()
157157-158158- // Create PDS record
159159- record := atproto.NewHoldWebhookRecord(userDID, triggers)
160160- _, recordCID, err := sb.pds.repomgr.PutRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey, record)
161161- if err != nil {
162162- return "", cid.Undef, fmt.Errorf("failed to create webhook PDS record: %w", err)
163163- }
164164-165165- // Store secret in SQLite
166166- _, err = sb.db.Exec(`
167167- INSERT INTO webhook_secrets (rkey, user_did, url, secret) VALUES (?, ?, ?, ?)
168168- `, rkey, userDID, webhookURL, secret)
169169- if err != nil {
170170- // Try to clean up PDS record on SQLite failure
171171- _ = sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey)
172172- return "", cid.Undef, fmt.Errorf("failed to store webhook secret: %w", err)
173173- }
174174-175175- return rkey, recordCID, nil
176176-}
177177-178178-// DeleteWebhookConfig deletes a webhook by rkey (validates ownership)
179179-func (sb *ScanBroadcaster) DeleteWebhookConfig(userDID, rkey string) error {
180180- ctx := context.Background()
181181-182182- // Validate ownership
183183- var owner string
184184- err := sb.db.QueryRow(`SELECT user_did FROM webhook_secrets WHERE rkey = ?`, rkey).Scan(&owner)
185185- if err != nil {
186186- return fmt.Errorf("webhook not found")
187187- }
188188- if owner != userDID {
189189- return fmt.Errorf("unauthorized: webhook belongs to a different user")
190190- }
191191-192192- // Delete SQLite row
193193- if _, err := sb.db.Exec(`DELETE FROM webhook_secrets WHERE rkey = ?`, rkey); err != nil {
194194- return fmt.Errorf("failed to delete webhook secret: %w", err)
195195- }
196196-197197- // Delete PDS record
198198- if err := sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey); err != nil {
199199- slog.Warn("Failed to delete webhook PDS record (secret already removed)", "rkey", rkey, "error", err)
200200- }
201201-202202- return nil
203203-}
204204-205205-// GetWebhooksForUser returns all active webhooks with secrets for dispatch
206206-func (sb *ScanBroadcaster) GetWebhooksForUser(userDID string) ([]activeWebhook, error) {
207207- rows, err := sb.db.Query(`
208208- SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
209209- `, userDID)
210210- if err != nil {
211211- return nil, err
212212- }
213213- defer rows.Close()
214214-215215- var webhooks []activeWebhook
216216- for rows.Next() {
217217- var w activeWebhook
218218- if err := rows.Scan(&w.Rkey, &w.URL, &w.Secret); err != nil {
219219- continue
220220- }
221221-222222- // Get triggers from PDS record
223223- _, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, w.Rkey, cid.Undef)
224224- if err == nil {
225225- if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
226226- w.Triggers = int(rec.Triggers)
227227- }
228228- }
229229-230230- webhooks = append(webhooks, w)
231231- }
232232- return webhooks, nil
233233-}
234234-235235-// dispatchWebhooks fires matching webhooks after a scan completes
236236-func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle string, summary *VulnerabilitySummary, previousScan *atproto.ScanRecord) {
237237- webhooks, err := sb.GetWebhooksForUser(userDID)
238238- if err != nil || len(webhooks) == 0 {
239239- return
240240- }
241241-242242- isFirst := previousScan == nil
243243- isChanged := previousScan != nil && vulnCountsChanged(summary, previousScan)
244244-245245- scanInfo := WebhookScanInfo{
246246- ScannedAt: time.Now().Format(time.RFC3339),
247247- ScannerVersion: "atcr-scanner-v1.0.0",
248248- Vulnerabilities: WebhookVulnCounts{
249249- Critical: summary.Critical,
250250- High: summary.High,
251251- Medium: summary.Medium,
252252- Low: summary.Low,
253253- Total: summary.Total,
254254- },
255255- }
256256-257257- manifestInfo := WebhookManifestInfo{
258258- Digest: manifestDigest,
259259- Repository: repository,
260260- Tag: tag,
261261- UserDID: userDID,
262262- UserHandle: userHandle,
263263- }
264264-265265- for _, wh := range webhooks {
266266- // Check each trigger condition
267267- triggers := []string{}
268268- if wh.Triggers&atproto.TriggerFirst != 0 && isFirst {
269269- triggers = append(triggers, "scan:first")
270270- }
271271- if wh.Triggers&atproto.TriggerAll != 0 {
272272- triggers = append(triggers, "scan:all")
273273- }
274274- if wh.Triggers&atproto.TriggerChanged != 0 && isChanged {
275275- triggers = append(triggers, "scan:changed")
276276- }
277277-278278- for _, trigger := range triggers {
279279- payload := WebhookPayload{
280280- Trigger: trigger,
281281- HoldDID: sb.holdDID,
282282- HoldEndpoint: sb.holdEndpoint,
283283- Manifest: manifestInfo,
284284- Scan: scanInfo,
285285- }
286286-287287- // Include previous counts for scan:changed
288288- if trigger == "scan:changed" && previousScan != nil {
289289- payload.Previous = &WebhookVulnCounts{
290290- Critical: int(previousScan.Critical),
291291- High: int(previousScan.High),
292292- Medium: int(previousScan.Medium),
293293- Low: int(previousScan.Low),
294294- Total: int(previousScan.Total),
295295- }
296296- }
297297-298298- payloadBytes, err := json.Marshal(payload)
299299- if err != nil {
300300- slog.Error("Failed to marshal webhook payload", "error", err)
301301- continue
302302- }
303303-304304- go sb.deliverWithRetry(wh.URL, wh.Secret, payloadBytes)
305305- }
306306- }
307307-}
308308-309309-// deliverWithRetry attempts to deliver a webhook with exponential backoff
310310-func (sb *ScanBroadcaster) deliverWithRetry(webhookURL, secret string, payload []byte) {
311311- delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute}
312312- for attempt, delay := range delays {
313313- if attempt > 0 {
314314- time.Sleep(delay)
315315- }
316316- if sb.attemptDelivery(webhookURL, secret, payload) {
317317- return
318318- }
319319- }
320320- slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL))
321321-}
322322-323323-// attemptDelivery sends a single webhook HTTP POST
324324-func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []byte) bool {
325325- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
326326- defer cancel()
327327-328328- // Reformat payload for platform-specific webhook APIs
329329- meta := sb.pds.AppviewMeta()
330330- sendPayload := payload
331331- if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) {
332332- var p WebhookPayload
333333- if err := json.Unmarshal(payload, &p); err == nil {
334334- var formatted []byte
335335- var fmtErr error
336336- if isDiscordWebhook(webhookURL) {
337337- formatted, fmtErr = formatDiscordPayload(p, meta)
338338- } else {
339339- formatted, fmtErr = formatSlackPayload(p, meta)
340340- }
341341- if fmtErr == nil {
342342- sendPayload = formatted
343343- }
344344- }
345345- }
346346-347347- req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(sendPayload)))
348348- if err != nil {
349349- slog.Warn("Failed to create webhook request", "error", err)
350350- return false
351351- }
352352-353353- req.Header.Set("Content-Type", "application/json")
354354- req.Header.Set("User-Agent", meta.ClientShortName+"-Webhook/1.0")
355355-356356- // HMAC signing if secret is set (signs the actual payload sent)
357357- if secret != "" {
358358- mac := hmac.New(sha256.New, []byte(secret))
359359- mac.Write(sendPayload)
360360- sig := hex.EncodeToString(mac.Sum(nil))
361361- req.Header.Set("X-Webhook-Signature-256", "sha256="+sig)
362362- }
363363-364364- client := &http.Client{Timeout: 10 * time.Second}
365365- resp, err := client.Do(req)
366366- if err != nil {
367367- slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
368368- return false
369369- }
370370- defer resp.Body.Close()
371371-372372- if resp.StatusCode >= 200 && resp.StatusCode < 300 {
373373- slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode)
374374- return true
375375- }
376376-377377- // Read response body for debugging (e.g., Discord returns error details)
378378- body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
379379- slog.Warn("Webhook delivery got non-2xx response",
380380- "url", maskURL(webhookURL),
381381- "status", resp.StatusCode,
382382- "body", string(body))
383383- return false
384384-}
385385-386386-// vulnCountsChanged checks if vulnerability counts differ between current scan and previous
387387-func vulnCountsChanged(current *VulnerabilitySummary, previous *atproto.ScanRecord) bool {
388388- return current.Critical != int(previous.Critical) ||
389389- current.High != int(previous.High) ||
390390- current.Medium != int(previous.Medium) ||
391391- current.Low != int(previous.Low)
392392-}
393393-394394-// maskURL masks a URL for display (shows scheme + host, hides path/query)
395395-func maskURL(rawURL string) string {
396396- u, err := url.Parse(rawURL)
397397- if err != nil {
398398- if len(rawURL) > 30 {
399399- return rawURL[:30] + "***"
400400- }
401401- return rawURL
402402- }
403403- masked := u.Scheme + "://" + u.Host
404404- if u.Path != "" && u.Path != "/" {
405405- masked += "/***"
406406- }
407407- return masked
408408-}
409409-410410-// isDiscordWebhook checks if the URL points to a Discord webhook endpoint
411411-func isDiscordWebhook(rawURL string) bool {
412412- u, err := url.Parse(rawURL)
413413- if err != nil {
414414- return false
415415- }
416416- return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com")
417417-}
418418-419419-// isSlackWebhook checks if the URL points to a Slack webhook endpoint
420420-func isSlackWebhook(rawURL string) bool {
421421- u, err := url.Parse(rawURL)
422422- if err != nil {
423423- return false
424424- }
425425- return u.Host == "hooks.slack.com"
426426-}
427427-428428-// webhookSeverityColor returns a color int based on the highest severity present
429429-func webhookSeverityColor(vulns WebhookVulnCounts) int {
430430- switch {
431431- case vulns.Critical > 0:
432432- return 0xED4245 // red
433433- case vulns.High > 0:
434434- return 0xFFA500 // orange
435435- case vulns.Medium > 0:
436436- return 0xFEE75C // yellow
437437- case vulns.Low > 0:
438438- return 0x57F287 // green
439439- default:
440440- return 0x95A5A6 // grey
441441- }
442442-}
443443-444444-// webhookSeverityHex returns a hex color string (e.g., "#ED4245")
445445-func webhookSeverityHex(vulns WebhookVulnCounts) string {
446446- return fmt.Sprintf("#%06X", webhookSeverityColor(vulns))
447447-}
448448-449449-// formatVulnDescription builds a vulnerability summary with colored square emojis
450450-func formatVulnDescription(v WebhookVulnCounts, digest string) string {
451451- var lines []string
452452-453453- if len(digest) > 19 {
454454- lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"..."))
455455- }
456456-457457- if v.Total == 0 {
458458- lines = append(lines, "🟩 No vulnerabilities found")
459459- } else {
460460- if v.Critical > 0 {
461461- lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical))
462462- }
463463- if v.High > 0 {
464464- lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High))
465465- }
466466- if v.Medium > 0 {
467467- lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium))
468468- }
469469- if v.Low > 0 {
470470- lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low))
471471- }
472472- }
473473-474474- return strings.Join(lines, "\n")
475475-}
476476-477477-// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format
478478-func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
479479- appviewURL := meta.BaseURL
480480- title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
481481-482482- description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest)
483483-484484- // Add previous counts for scan:changed
485485- if p.Trigger == "scan:changed" && p.Previous != nil {
486486- description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
487487- p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
488488- }
489489-490490- embed := map[string]any{
491491- "title": title,
492492- "url": appviewURL,
493493- "description": description,
494494- "color": webhookSeverityColor(p.Scan.Vulnerabilities),
495495- "footer": map[string]string{
496496- "text": meta.ClientShortName,
497497- "icon_url": meta.FaviconURL,
498498- },
499499- "timestamp": p.Scan.ScannedAt,
500500- }
501501-502502- // Add author, repo link, and OG image when handle is available
503503- if p.Manifest.UserHandle != "" {
504504- embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
505505- embed["author"] = map[string]string{
506506- "name": p.Manifest.UserHandle,
507507- "url": appviewURL + "/u/" + p.Manifest.UserHandle,
508508- }
509509- embed["image"] = map[string]string{
510510- "url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository),
511511- }
512512- } else {
513513- embed["image"] = map[string]string{
514514- "url": appviewURL + "/og/home",
515515- }
516516- }
517517-518518- payload := map[string]any{
519519- "username": meta.ClientShortName,
520520- "avatar_url": meta.FaviconURL,
521521- "embeds": []any{embed},
522522- }
523523- return json.Marshal(payload)
524524-}
525525-526526-// formatSlackPayload wraps an ATCR webhook payload in Slack's message format
527527-func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
528528- appviewURL := meta.BaseURL
529529- title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
530530-531531- v := p.Scan.Vulnerabilities
532532- fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low",
533533- title, v.Critical, v.High, v.Medium, v.Low)
534534-535535- description := formatVulnDescription(v, p.Manifest.Digest)
536536-537537- // Add previous counts for scan:changed
538538- if p.Trigger == "scan:changed" && p.Previous != nil {
539539- description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
540540- p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
541541- }
542542-543543- attachment := map[string]any{
544544- "fallback": fallback,
545545- "color": webhookSeverityHex(v),
546546- "title": title,
547547- "text": description,
548548- "footer": meta.ClientShortName,
549549- "footer_icon": meta.FaviconURL,
550550- "ts": p.Scan.ScannedAt,
551551- }
552552-553553- // Add repo link when handle is available
554554- if p.Manifest.UserHandle != "" {
555555- attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
556556- attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
557557- attachment["author_name"] = p.Manifest.UserHandle
558558- attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle
559559- }
560560-561561- payload := map[string]any{
562562- "text": fallback,
563563- "attachments": []any{attachment},
564564- }
565565- return json.Marshal(payload)
566566-}
567567-568568-// isCaptain checks if the given DID is the hold captain (owner)
569569-func (h *XRPCHandler) isCaptain(ctx context.Context, did string) bool {
570570- _, captain, err := h.pds.GetCaptainRecord(ctx)
571571- if err != nil {
572572- slog.Debug("isCaptain: failed to get captain record", "error", err)
573573- return false
574574- }
575575- if captain == nil {
576576- slog.Debug("isCaptain: captain record is nil")
577577- return false
578578- }
579579- match := captain.Owner == did
580580- if !match {
581581- slog.Debug("isCaptain: DID mismatch", "captain.Owner", captain.Owner, "user.DID", did)
582582- }
583583- return match
584584-}
585585-586586-// ---- XRPC Handlers ----
587587-588588-// HandleListWebhooks returns webhook configs for a user
589589-func (h *XRPCHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) {
590590- user := getUserFromContext(r)
591591- if user == nil {
592592- http.Error(w, "authentication required", http.StatusUnauthorized)
593593- return
594594- }
595595-596596- if h.scanBroadcaster == nil {
597597- render.JSON(w, r, map[string]any{
598598- "webhooks": []any{},
599599- "limits": map[string]any{"max": 0, "allTriggers": false},
600600- })
601601- return
602602- }
603603-604604- configs, err := h.scanBroadcaster.ListWebhookConfigs(user.DID)
605605- if err != nil {
606606- http.Error(w, fmt.Sprintf("failed to list webhooks: %v", err), http.StatusInternalServerError)
607607- return
608608- }
609609-610610- // Get tier limits — captains get unlimited access
611611- maxWebhooks, allTriggers := 1, false
612612- if h.isCaptain(r.Context(), user.DID) {
613613- maxWebhooks, allTriggers = -1, true
614614- } else if h.quotaMgr != nil {
615615- _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
616616- tierKey := ""
617617- if crew != nil {
618618- tierKey = crew.Tier
619619- }
620620- maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
621621- }
622622-623623- render.JSON(w, r, map[string]any{
624624- "webhooks": configs,
625625- "limits": map[string]any{
626626- "max": maxWebhooks,
627627- "allTriggers": allTriggers,
628628- },
629629- })
630630-}
631631-632632-// HandleAddWebhook creates a new webhook configuration
633633-func (h *XRPCHandler) HandleAddWebhook(w http.ResponseWriter, r *http.Request) {
634634- user := getUserFromContext(r)
635635- if user == nil {
636636- http.Error(w, "authentication required", http.StatusUnauthorized)
637637- return
638638- }
639639-640640- if h.scanBroadcaster == nil {
641641- http.Error(w, "scanning not enabled", http.StatusNotImplemented)
642642- return
643643- }
644644-645645- var req struct {
646646- URL string `json:"url"`
647647- Secret string `json:"secret"`
648648- Triggers int `json:"triggers"`
649649- }
650650- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
651651- http.Error(w, "invalid request body", http.StatusBadRequest)
652652- return
653653- }
654654-655655- // Validate HTTPS URL
656656- u, err := url.Parse(req.URL)
657657- if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
658658- http.Error(w, "invalid webhook URL: must be https", http.StatusBadRequest)
659659- return
660660- }
661661-662662- // Tier enforcement — captains get unlimited access
663663- maxWebhooks, allTriggers := 1, false
664664- if h.isCaptain(r.Context(), user.DID) {
665665- maxWebhooks, allTriggers = -1, true
666666- } else {
667667- tierKey := ""
668668- _, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
669669- if crew != nil {
670670- tierKey = crew.Tier
671671- }
672672- if h.quotaMgr != nil {
673673- maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
674674- }
675675- }
676676-677677- // Check webhook count limit
678678- count, err := h.scanBroadcaster.CountWebhooks(user.DID)
679679- if err != nil {
680680- http.Error(w, "failed to check webhook count", http.StatusInternalServerError)
681681- return
682682- }
683683- if maxWebhooks >= 0 && count >= maxWebhooks {
684684- http.Error(w, fmt.Sprintf("webhook limit reached (%d/%d)", count, maxWebhooks), http.StatusForbidden)
685685- return
686686- }
687687-688688- // Trigger bitmask enforcement: free users can only set TriggerFirst
689689- if !allTriggers && req.Triggers & ^atproto.TriggerFirst != 0 {
690690- http.Error(w, "trigger types beyond scan:first require a paid tier", http.StatusForbidden)
691691- return
692692- }
693693-694694- rkey, recordCID, err := h.scanBroadcaster.AddWebhookConfig(user.DID, req.URL, req.Secret, req.Triggers)
695695- if err != nil {
696696- http.Error(w, fmt.Sprintf("failed to add webhook: %v", err), http.StatusInternalServerError)
697697- return
698698- }
699699-700700- render.Status(r, http.StatusCreated)
701701- render.JSON(w, r, map[string]any{
702702- "rkey": rkey,
703703- "cid": recordCID.String(),
704704- })
705705-}
706706-707707-// HandleDeleteWebhook deletes a webhook configuration
708708-func (h *XRPCHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
709709- user := getUserFromContext(r)
710710- if user == nil {
711711- http.Error(w, "authentication required", http.StatusUnauthorized)
712712- return
713713- }
714714-715715- if h.scanBroadcaster == nil {
716716- http.Error(w, "scanning not enabled", http.StatusNotImplemented)
717717- return
718718- }
719719-720720- var req struct {
721721- Rkey string `json:"rkey"`
722722- }
723723- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
724724- http.Error(w, "invalid request body", http.StatusBadRequest)
725725- return
726726- }
727727-728728- if err := h.scanBroadcaster.DeleteWebhookConfig(user.DID, req.Rkey); err != nil {
729729- if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
730730- http.Error(w, err.Error(), http.StatusForbidden)
731731- } else {
732732- http.Error(w, fmt.Sprintf("failed to delete webhook: %v", err), http.StatusInternalServerError)
733733- }
734734- return
735735- }
736736-737737- render.JSON(w, r, map[string]any{"success": true})
738738-}
739739-740740-// HandleTestWebhook sends a test payload to a webhook
741741-func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) {
742742- user := getUserFromContext(r)
743743- if user == nil {
744744- http.Error(w, "authentication required", http.StatusUnauthorized)
745745- return
746746- }
747747-748748- if h.scanBroadcaster == nil {
749749- http.Error(w, "scanning not enabled", http.StatusNotImplemented)
750750- return
751751- }
752752-753753- var req struct {
754754- Rkey string `json:"rkey"`
755755- }
756756- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
757757- http.Error(w, "invalid request body", http.StatusBadRequest)
758758- return
759759- }
760760-761761- // Look up webhook URL and secret
762762- var webhookURL, secret, owner string
763763- err := h.scanBroadcaster.db.QueryRow(`
764764- SELECT url, secret, user_did FROM webhook_secrets WHERE rkey = ?
765765- `, req.Rkey).Scan(&webhookURL, &secret, &owner)
766766- if err != nil {
767767- http.Error(w, "webhook not found", http.StatusNotFound)
768768- return
769769- }
770770- if owner != user.DID {
771771- http.Error(w, "unauthorized", http.StatusForbidden)
772772- return
773773- }
774774-775775- // Resolve handle if not available from auth context
776776- userHandle := user.Handle
777777- if userHandle == "" {
778778- if _, handle, _, err := atproto.ResolveIdentity(r.Context(), user.DID); err == nil {
779779- userHandle = handle
780780- }
781781- }
782782-783783- // Randomize vulnerability counts so each test shows a different severity color
784784- critical := rand.IntN(3)
785785- high := rand.IntN(5)
786786- medium := rand.IntN(8)
787787- low := rand.IntN(10)
788788- total := critical + high + medium + low
789789-790790- // Build test payload
791791- payload := WebhookPayload{
792792- Trigger: "test",
793793- HoldDID: h.scanBroadcaster.holdDID,
794794- HoldEndpoint: h.scanBroadcaster.holdEndpoint,
795795- Manifest: WebhookManifestInfo{
796796- Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
797797- Repository: "test-repo",
798798- Tag: "latest",
799799- UserDID: user.DID,
800800- UserHandle: userHandle,
801801- },
802802- Scan: WebhookScanInfo{
803803- ScannedAt: time.Now().Format(time.RFC3339),
804804- ScannerVersion: "atcr-scanner-v1.0.0",
805805- Vulnerabilities: WebhookVulnCounts{
806806- Critical: critical, High: high, Medium: medium, Low: low, Total: total,
807807- },
808808- },
809809- }
810810-811811- payloadBytes, _ := json.Marshal(payload)
812812-813813- // Deliver test payload synchronously
814814- success := h.scanBroadcaster.attemptDelivery(webhookURL, secret, payloadBytes)
815815-816816- render.JSON(w, r, map[string]any{
817817- "success": success,
818818- })
819819-}
820820-821821-// registerWebhookHandlers registers webhook XRPC handlers on the router.
822822-// Called from RegisterHandlers.
823823-func (h *XRPCHandler) registerWebhookHandlers(r chi.Router) {
824824- r.Group(func(r chi.Router) {
825825- r.Use(h.requireAuth)
826826- r.Get(atproto.HoldListWebhooks, h.HandleListWebhooks)
827827- r.Post(atproto.HoldAddWebhook, h.HandleAddWebhook)
828828- r.Post(atproto.HoldDeleteWebhook, h.HandleDeleteWebhook)
829829- r.Post(atproto.HoldTestWebhook, h.HandleTestWebhook)
830830- })
831831-}
+116-2
pkg/hold/pds/xrpc.go
···4949 scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners
5050 httpClient HTTPClient // For testing - allows injecting mock HTTP client
5151 quotaMgr *quota.Manager // Quota manager for tier-based limits
5252+ appviewDID string // DID of the trusted appview (for tier updates)
5253}
53545455// PartInfo represents a completed part in a multipart upload
···7475 httpClient: httpClient,
7576 quotaMgr: quotaMgr,
7677 }
7878+}
7979+8080+// SetAppviewDID sets the trusted appview DID for tier update authentication.
8181+func (h *XRPCHandler) SetAppviewDID(did string) {
8282+ h.appviewDID = did
7783}
78847985// SetScanBroadcaster sets the scan broadcaster for dispatching scan jobs to scanners
···209215 // Public quota endpoint (no auth - quota is per-user, just needs userDid param)
210216 r.Get(atproto.HoldGetQuota, h.HandleGetQuota)
211217218218+ // Public tier list endpoint (no auth)
219219+ r.Get(atproto.HoldListTiers, h.HandleListTiers)
220220+221221+ // Appview-authenticated endpoints (appview JWT auth)
222222+ r.Post(atproto.HoldUpdateCrewTier, h.HandleUpdateCrewTier)
223223+212224 // Scanner WebSocket endpoint (shared secret auth)
213225 r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs)
214226215215- // Webhook management endpoints (service token auth)
216216- h.registerWebhookHandlers(r)
217227}
218228219229// HandleHealth returns health check information
···16021612 }
1603161316041614 render.JSON(w, r, stats)
16151615+}
16161616+16171617+// HandleListTiers returns the hold's available tiers with storage quotas.
16181618+// This is a public endpoint (no auth) so the appview UI can display "3-10 GB depending on region."
16191619+func (h *XRPCHandler) HandleListTiers(w http.ResponseWriter, r *http.Request) {
16201620+ if !h.quotaMgr.IsEnabled() {
16211621+ render.JSON(w, r, map[string]any{"tiers": []any{}})
16221622+ return
16231623+ }
16241624+16251625+ tierInfos := h.quotaMgr.ListTiers()
16261626+ tiers := make([]map[string]any, 0, len(tierInfos))
16271627+ for _, t := range tierInfos {
16281628+ var quotaBytes int64
16291629+ if t.Limit != nil {
16301630+ quotaBytes = *t.Limit
16311631+ }
16321632+ tiers = append(tiers, map[string]any{
16331633+ "name": t.Key,
16341634+ "quotaBytes": quotaBytes,
16351635+ "quotaFormatted": quota.FormatHumanBytes(quotaBytes),
16361636+ "scanOnPush": t.ScanOnPush,
16371637+ })
16381638+ }
16391639+16401640+ render.JSON(w, r, map[string]any{"tiers": tiers})
16411641+}
16421642+16431643+// HandleUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview.
16441644+// Auth: Bearer token signed by the appview's P-256 key (ES256 JWT).
16451645+func (h *XRPCHandler) HandleUpdateCrewTier(w http.ResponseWriter, r *http.Request) {
16461646+ if h.appviewDID == "" {
16471647+ http.Error(w, "appview DID not configured on this hold", http.StatusServiceUnavailable)
16481648+ return
16491649+ }
16501650+16511651+ // Validate appview token
16521652+ userDID, err := ValidateAppviewToken(r, h.appviewDID, h.pds.DID())
16531653+ if err != nil {
16541654+ slog.Warn("Appview token validation failed for updateCrewTier", "error", err)
16551655+ http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
16561656+ return
16571657+ }
16581658+16591659+ // Parse request body
16601660+ var req struct {
16611661+ UserDID string `json:"userDid"`
16621662+ TierRank int `json:"tierRank"`
16631663+ }
16641664+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
16651665+ http.Error(w, "invalid request body", http.StatusBadRequest)
16661666+ return
16671667+ }
16681668+16691669+ // Verify the userDid in the body matches the sub claim
16701670+ if req.UserDID != "" && req.UserDID != userDID {
16711671+ // Use the body's userDid (the sub claim was the appview-specified user)
16721672+ userDID = req.UserDID
16731673+ }
16741674+16751675+ if !atproto.IsDID(userDID) {
16761676+ http.Error(w, "invalid userDid format", http.StatusBadRequest)
16771677+ return
16781678+ }
16791679+16801680+ // Map tier rank to tier name
16811681+ tierName := h.resolveTierByRank(req.TierRank)
16821682+ if tierName == "" {
16831683+ http.Error(w, "no tiers configured on this hold", http.StatusBadRequest)
16841684+ return
16851685+ }
16861686+16871687+ // Update the crew member's tier
16881688+ if err := h.pds.UpdateCrewMemberTier(r.Context(), userDID, tierName); err != nil {
16891689+ slog.Error("Failed to update crew tier", "userDid", userDID, "tier", tierName, "error", err)
16901690+ http.Error(w, fmt.Sprintf("failed to update tier: %v", err), http.StatusInternalServerError)
16911691+ return
16921692+ }
16931693+16941694+ slog.Info("Updated crew tier via appview", "userDid", userDID, "tierRank", req.TierRank, "tierName", tierName)
16951695+16961696+ render.JSON(w, r, map[string]string{"tierName": tierName})
16971697+}
16981698+16991699+// resolveTierByRank maps a 0-based rank index to a tier name from the quota config.
17001700+// If the rank exceeds the number of tiers, it clamps to the highest tier.
17011701+func (h *XRPCHandler) resolveTierByRank(rank int) string {
17021702+ if !h.quotaMgr.IsEnabled() {
17031703+ return ""
17041704+ }
17051705+17061706+ tiers := h.quotaMgr.ListTiers()
17071707+ if len(tiers) == 0 {
17081708+ return ""
17091709+ }
17101710+17111711+ if rank < 0 {
17121712+ rank = 0
17131713+ }
17141714+ if rank >= len(tiers) {
17151715+ rank = len(tiers) - 1
17161716+ }
17171717+17181718+ return tiers[rank].Key
16051719}
1606172016071721// HoldUserDataExport represents the GDPR data export from a hold service
+6-69
pkg/hold/quota/config.go
···40404141 // Whether pushing triggers an immediate vulnerability scan.
4242 ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."`
4343-4444- // Maximum number of webhook URLs a user can configure. 0 = none, -1 = unlimited.
4545- MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhook URLs (0=none, -1=unlimited). Default: 1."`
4646-4747- // Whether all trigger types are allowed. Free tiers only get scan:first.
4848- WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types. Free tiers only get scan:first."`
4949-5050- // Whether this tier earns a supporter badge on user profiles.
5151- SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for members at this tier."`
5243}
53445445// DefaultsConfig represents default settings.
5546type DefaultsConfig struct {
5647 // Name of the tier assigned to new crew members.
5748 NewCrewTier string `yaml:"new_crew_tier" comment:"Tier assigned to new crew members who don't have an explicit tier."`
5858-5959- // Whether the hold owner (captain) gets a supporter badge on their profile.
6060- OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on the hold owner's profile."`
6149}
62506351// Manager manages quota configuration and tier resolution
···220208 return false
221209}
222210223223-// WebhookLimits returns the webhook limits for a tier.
224224-// Returns (maxWebhooks, allTriggers). Default when no config: (1, false).
225225-// Follows the same fallback logic as GetTierLimit.
226226-func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bool) {
227227- if !m.IsEnabled() {
228228- return 1, false
229229- }
230230-231231- if tierKey != "" {
232232- if tier := m.config.TierByName(tierKey); tier != nil {
233233- max := tier.MaxWebhooks
234234- if max == 0 {
235235- max = 1 // default
236236- }
237237- return max, tier.WebhookAllTriggers
238238- }
239239- }
240240-241241- // Fall back to default tier
242242- if m.config.Defaults.NewCrewTier != "" {
243243- if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil {
244244- max := tier.MaxWebhooks
245245- if max == 0 {
246246- max = 1
247247- }
248248- return max, tier.WebhookAllTriggers
249249- }
250250- }
251251-252252- return 1, false
253253-}
254254-255255-// BadgeTiers returns the names of tiers that have supporter badges enabled,
256256-// ordered from highest rank to lowest. Includes "owner" first if
257257-// defaults.owner_badge is true.
258258-// Returns nil if quotas are disabled or no tiers have badges.
259259-func (m *Manager) BadgeTiers() []string {
260260- if !m.IsEnabled() {
261261- return nil
262262- }
263263- var tiers []string
264264- if m.config.Defaults.OwnerBadge {
265265- tiers = append(tiers, "owner")
266266- }
267267- // Iterate in reverse: highest rank first
268268- for i := len(m.config.Tiers) - 1; i >= 0; i-- {
269269- if m.config.Tiers[i].SupporterBadge {
270270- tiers = append(tiers, m.config.Tiers[i].Name)
271271- }
272272- }
273273- return tiers
274274-}
275275-276211// TierCount returns the number of configured tiers
277212func (m *Manager) TierCount() int {
278213 return len(m.tiers)
···280215281216// TierInfo represents tier information for listing
282217type TierInfo struct {
283283- Key string
284284- Limit *int64
218218+ Key string
219219+ Limit *int64
220220+ ScanOnPush bool
285221}
286222287223// ListTiers returns all configured tiers with their limits, in rank order
···299235 }
300236 limitCopy := limit
301237 tiers = append(tiers, TierInfo{
302302- Key: tier.Name,
303303- Limit: &limitCopy,
238238+ Key: tier.Name,
239239+ Limit: &limitCopy,
240240+ ScanOnPush: tier.ScanOnPush,
304241 })
305242 }
306243 return tiers