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

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

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+39 -34
View File
@@ -11,7 +11,9 @@ import (
"atcr.io/pkg/appview/holdhealth"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/appview/webhooks"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/billing"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/go-chi/chi/v5"
)
@@ -24,22 +26,24 @@ type LegalConfig struct {
// UIDependencies contains all dependencies needed for UI route registration
type UIDependencies struct {
Database *sql.DB
ReadOnlyDB *sql.DB
SessionStore *db.SessionStore
OAuthClientApp *indigooauth.ClientApp
OAuthStore *db.OAuthStore
Refresher *oauth.Refresher
BaseURL string
RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL
DeviceStore *db.DeviceStore
HealthChecker *holdhealth.Checker
ReadmeFetcher *readme.Fetcher
Templates *template.Template
DefaultHoldDID string
LegalConfig LegalConfig
ClientName string // Full name: "AT Container Registry"
ClientShortName string // Short name: "ATCR"
Database *sql.DB
ReadOnlyDB *sql.DB
SessionStore *db.SessionStore
OAuthClientApp *indigooauth.ClientApp
OAuthStore *db.OAuthStore
Refresher *oauth.Refresher
BaseURL string
RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL
DeviceStore *db.DeviceStore
HealthChecker *holdhealth.Checker
ReadmeFetcher *readme.Fetcher
Templates *template.Template
DefaultHoldDID string
LegalConfig LegalConfig
ClientName string // Full name: "AT Container Registry"
ClientShortName string // Short name: "ATCR"
BillingManager *billing.Manager // Stripe billing manager (nil if not configured)
WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured)
}
// RegisterUIRoutes registers all web UI and API routes on the provided router
@@ -55,23 +59,25 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
// Create base with all dependencies - handlers just embed this
base := uihandlers.BaseUIHandler{
Templates: deps.Templates,
RegistryURL: registryURL,
SiteURL: siteURL,
DB: deps.Database,
ReadOnlyDB: deps.ReadOnlyDB,
Refresher: deps.Refresher,
HealthChecker: deps.HealthChecker,
ReadmeFetcher: deps.ReadmeFetcher,
Directory: deps.OAuthClientApp.Dir,
SessionStore: deps.SessionStore,
DeviceStore: deps.DeviceStore,
OAuthStore: deps.OAuthStore,
DefaultHoldDID: deps.DefaultHoldDID,
CompanyName: deps.LegalConfig.CompanyName,
Jurisdiction: deps.LegalConfig.Jurisdiction,
ClientName: deps.ClientName,
ClientShortName: deps.ClientShortName,
Templates: deps.Templates,
RegistryURL: registryURL,
SiteURL: siteURL,
DB: deps.Database,
ReadOnlyDB: deps.ReadOnlyDB,
Refresher: deps.Refresher,
HealthChecker: deps.HealthChecker,
ReadmeFetcher: deps.ReadmeFetcher,
Directory: deps.OAuthClientApp.Dir,
SessionStore: deps.SessionStore,
DeviceStore: deps.DeviceStore,
OAuthStore: deps.OAuthStore,
BillingManager: deps.BillingManager,
WebhookDispatcher: deps.WebhookDispatcher,
DefaultHoldDID: deps.DefaultHoldDID,
CompanyName: deps.LegalConfig.CompanyName,
Jurisdiction: deps.LegalConfig.Jurisdiction,
ClientName: deps.ClientName,
ClientShortName: deps.ClientShortName,
}
// OAuth login routes (public)
@@ -154,7 +160,6 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP)
// Subscription management
r.Get("/api/subscription", (&uihandlers.SubscriptionHandler{BaseUIHandler: base}).ServeHTTP)
r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP)
r.Get("/settings/subscription/portal", (&uihandlers.SubscriptionPortalHandler{BaseUIHandler: base}).ServeHTTP)
+173 -29
View File
@@ -28,12 +28,15 @@ import (
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/appview/routes"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/appview/webhooks"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
"atcr.io/pkg/billing"
"atcr.io/pkg/logging"
"github.com/bluesky-social/indigo/atproto/atcrypto"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
)
@@ -95,6 +98,15 @@ type AppViewServer struct {
// HoldAuthorizer checks hold access permissions.
HoldAuthorizer auth.HoldAuthorizer
// OAuthKey is the P-256 private key used for OAuth client auth and appview service identity.
OAuthKey *atcrypto.PrivateKeyP256
// BillingManager handles Stripe billing and tier updates (nil if billing disabled).
BillingManager *billing.Manager
// WebhookDispatcher dispatches scan webhooks (stored in appview DB).
WebhookDispatcher *webhooks.Dispatcher
// Private fields for lifecycle management
oauthHooks []OAuthPostAuthHook
tokenHooks []TokenPostAuthHook
@@ -191,6 +203,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
if err != nil {
return nil, fmt.Errorf("failed to load OAuth key: %w", err)
}
s.OAuthKey = oauthKey
// Create OAuth client app
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
@@ -236,6 +249,39 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
}()
}
// Initialize billing manager
appviewDID := DIDFromBaseURL(baseURL)
s.BillingManager = billing.New(
&cfg.Billing,
oauthKey,
appviewDID,
cfg.Server.ManagedHolds,
baseURL,
)
// Allow hold captains to bypass billing feature gates
if len(cfg.Server.ManagedHolds) > 0 {
managedHolds := cfg.Server.ManagedHolds
roDB := s.ReadOnlyDB
s.BillingManager.SetCaptainChecker(func(userDID string) bool {
isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds)
return isCaptain
})
}
if s.BillingManager.Enabled() {
slog.Info("Billing enabled", "appview_did", appviewDID, "managed_holds", len(cfg.Server.ManagedHolds))
go s.BillingManager.RefreshHoldTiers()
}
// Create webhook dispatcher
appviewMeta := atproto.AppviewMetadata{
ClientName: cfg.Server.ClientName,
ClientShortName: cfg.Server.ClientShortName,
BaseURL: cfg.Server.BaseURL,
FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png",
RegistryDomains: cfg.Server.RegistryDomains,
}
s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta)
// Initialize Jetstream workers
s.initializeJetstream()
@@ -264,27 +310,32 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
// Register UI routes
routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{
Database: s.Database,
ReadOnlyDB: s.ReadOnlyDB,
SessionStore: s.SessionStore,
OAuthClientApp: s.OAuthClientApp,
OAuthStore: s.OAuthStore,
Refresher: s.Refresher,
BaseURL: baseURL,
RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains),
DeviceStore: s.DeviceStore,
HealthChecker: s.HealthChecker,
ReadmeFetcher: s.ReadmeFetcher,
Templates: s.Templates,
DefaultHoldDID: defaultHoldDID,
ClientName: cfg.Server.ClientName,
ClientShortName: cfg.Server.ClientShortName,
Database: s.Database,
ReadOnlyDB: s.ReadOnlyDB,
SessionStore: s.SessionStore,
OAuthClientApp: s.OAuthClientApp,
OAuthStore: s.OAuthStore,
Refresher: s.Refresher,
BaseURL: baseURL,
RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains),
DeviceStore: s.DeviceStore,
HealthChecker: s.HealthChecker,
ReadmeFetcher: s.ReadmeFetcher,
Templates: s.Templates,
DefaultHoldDID: defaultHoldDID,
ClientName: cfg.Server.ClientName,
ClientShortName: cfg.Server.ClientShortName,
BillingManager: s.BillingManager,
WebhookDispatcher: s.WebhookDispatcher,
LegalConfig: routes.LegalConfig{
CompanyName: cfg.Legal.CompanyName,
Jurisdiction: cfg.Legal.Jurisdiction,
},
})
// Register Stripe webhook route (if billing enabled)
s.BillingManager.RegisterRoutes(mainRouter)
// Create OAuth server
s.OAuthServer = oauth.NewServer(s.OAuthClientApp)
s.OAuthServer.SetRefresher(s.Refresher)
@@ -550,6 +601,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
}
})
// Appview DID document endpoint (service identity for key discovery)
mainRouter.Get("/.well-known/did.json", s.handleDIDDocument)
// Register credential helper version API (public endpoint)
routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo)
@@ -692,12 +746,99 @@ func primaryRegistryDomain(domains []string) string {
return ""
}
// DID returns the appview's did:web identity derived from its BaseURL.
func (s *AppViewServer) DID() string {
return DIDFromBaseURL(s.Config.Server.BaseURL)
}
// DIDFromBaseURL derives a did:web identifier from a base URL.
// Per the did:web spec, non-standard ports are percent-encoded.
// Examples:
//
// "https://atcr.io" → "did:web:atcr.io"
// "http://localhost:5000" → "did:web:localhost%3A5000"
func DIDFromBaseURL(baseURL string) string {
u, err := url.Parse(baseURL)
if err != nil {
return "did:web:localhost"
}
hostname := u.Hostname()
if hostname == "" {
hostname = "localhost"
}
port := u.Port()
isStandardPort := (u.Scheme == "https" && port == "443") ||
(u.Scheme == "http" && port == "80") ||
port == ""
if isStandardPort {
return "did:web:" + hostname
}
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
}
// handleDIDDocument serves the appview's DID document at /.well-known/did.json.
// This is a service identity for key discovery — no PDS, no repo, no firehose.
// Holds use this to discover the appview's P-256 public key for JWT verification.
func (s *AppViewServer) handleDIDDocument(w http.ResponseWriter, r *http.Request) {
did := s.DID()
pubKey, err := s.OAuthKey.PublicKey()
if err != nil {
slog.Error("Failed to get public key for DID document", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
doc := map[string]any{
"@context": []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
},
"id": did,
"verificationMethod": []map[string]any{
{
"id": did + "#appview",
"type": "Multikey",
"controller": did,
"publicKeyMultibase": pubKey.Multibase(),
},
},
"authentication": []string{
did + "#appview",
},
"assertionMethod": []string{
did + "#appview",
},
"service": []map[string]any{
{
"id": "#atcr_appview",
"type": "AtcrAppView",
"serviceEndpoint": s.Config.Server.BaseURL,
},
},
}
w.Header().Set("Content-Type", "application/did+ld+json")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
if err := json.NewEncoder(w).Encode(doc); err != nil {
slog.Error("Failed to encode DID document", "error", err)
}
}
// initializeJetstream initializes the Jetstream workers for real-time events and backfill.
func (s *AppViewServer) initializeJetstream() {
jetstreamURLs := s.Config.Jetstream.URLs
go func() {
worker := jetstream.NewWorker(s.Database, jetstreamURLs, 0)
// Set webhook dispatcher on live worker (backfill skips dispatch)
if s.WebhookDispatcher != nil {
worker.Processor().SetWebhookDispatcher(s.WebhookDispatcher)
}
worker.StartWithFailover(context.Background())
}()
slog.Info("Jetstream real-time worker started", "component", "jetstream", "endpoints", len(jetstreamURLs))
@@ -724,22 +865,25 @@ func (s *AppViewServer) initializeJetstream() {
}
}()
interval := 1 * time.Hour
interval := s.Config.Jetstream.BackfillInterval
if interval > 0 {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval)
if err := backfillWorker.Start(context.Background()); err != nil {
slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err)
} else {
slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill")
for range ticker.C {
slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval)
if err := backfillWorker.Start(context.Background()); err != nil {
slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err)
} else {
slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill")
}
}
}
}()
slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval)
}()
slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval)
} else {
slog.Info("Periodic backfill disabled (interval=0), only startup backfill will run", "component", "jetstream/backfill")
}
}
}
}
+2 -2
View File
@@ -734,9 +734,9 @@ function showToast(message, type) {
}
// Test webhook via fetch + toast
async function testWebhook(rkey) {
async function testWebhook(id) {
try {
const resp = await fetch(`/api/webhooks/${rkey}/test`, {
const resp = await fetch(`/api/webhooks/${id}/test`, {
method: 'POST',
credentials: 'include',
});
+47 -215
View File
@@ -9,20 +9,22 @@
{{ template "nav" . }}
<main class="container mx-auto px-4 py-8">
<div class="max-w-5xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Settings</h1>
<!-- Mobile identity info (below lg) -->
<div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/50">
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
</div>
<!-- Mobile tab bar (below lg) -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="identity">
{{ icon "fingerprint" "size-4" }} Identity
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
{{ icon "hard-drive" "size-4" }} Storage
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices">
{{ icon "terminal" "size-4" }} Devices
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
{{ icon "hard-drive" "size-4" }} Storage
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks">
{{ icon "webhook" "size-4" }} Webhooks
</button>
@@ -35,36 +37,49 @@
<!-- Sidebar (lg and above) -->
<aside class="hidden lg:block w-56 shrink-0">
<ul class="menu bg-base-200 rounded-box w-full">
<li data-tab="identity"><a href="#identity">{{ icon "fingerprint" "size-4" }} Identity</a></li>
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
<li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li>
<li data-tab="advanced"><a href="#advanced">{{ icon "shield-check" "size-4" }} Advanced</a></li>
</ul>
<div class="mt-4 px-2 space-y-1 text-xs text-base-content/50">
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
</div>
</aside>
<!-- Tab content -->
<div class="flex-1 min-w-0">
<!-- IDENTITY TAB -->
<div id="tab-identity" class="settings-panel space-y-6">
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Identity</h2>
<div class="grid gap-3">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-content/70">Handle</span>
<span>{{ .Profile.Handle }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-content/70">DID</span>
<code class="cmd">{{ .Profile.DID }}</code>
</div>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-content/70">PDS</span>
<span>{{ .Profile.PDSEndpoint }}</span>
<!-- STORAGE TAB -->
<div id="tab-storage" class="settings-panel space-y-4">
<!-- Available Plans -->
{{ template "subscription_plans" .Subscription }}
<!-- Holds -->
{{ if .AllHolds }}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-4">
{{ template "hold_selector" . }}
{{ if .ActiveHold }}
{{ template "hold_card" .ActiveHold }}
{{ else }}
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
No active hold selected. Choose one above.
</div>
{{ end }}
</div>
</section>
<div>
{{ if .OtherHolds }}
{{ template "other_holds_table" .OtherHolds }}
{{ end }}
</div>
</div>
{{ else }}
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
No holds configured. Push an image to get started.
</div>
{{ end }}
</div>
<!-- DEVICES TAB -->
@@ -127,117 +142,15 @@
</section>
</div>
<!-- STORAGE TAB -->
<div id="tab-storage" class="settings-panel hidden space-y-6">
<!-- Default Hold Section -->
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Default Hold</h2>
<p class="text-base-content/70">Select where your container images will be stored.</p>
<form hx-post="/api/profile/default-hold"
hx-target="#hold-status"
hx-swap="innerHTML"
id="hold-form"
class="space-y-4">
<fieldset class="fieldset">
<legend class="sr-only">Storage hold selection</legend>
<label class="label" for="default-hold">
<span class="label-text">Storage Hold</span>
</label>
<select id="default-hold" name="hold_did" class="select select-bordered w-full" autocomplete="off">
<option value="{{ .AppViewDefaultHoldDID }}"{{ if or (eq .CurrentHoldDID "") (eq .CurrentHoldDID .AppViewDefaultHoldDID) }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
{{ if .ShowCurrentHold }}
<option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
{{ end }}
{{ if .OwnedHolds }}
<optgroup label="Your Holds">
{{ range .OwnedHolds }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
</option>
{{ end }}
</optgroup>
{{ end }}
{{ if .CrewHolds }}
<optgroup label="Crew Member">
{{ range .CrewHolds }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
</option>
{{ end }}
</optgroup>
{{ end }}
{{ if .EligibleHolds }}
<optgroup label="Open Registration">
{{ range .EligibleHolds }}
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
</option>
{{ end }}
</optgroup>
{{ end }}
</select>
<p class="text-sm text-base-content/60 mt-1">Your images will be stored on the selected hold</p>
</fieldset>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<div id="hold-status"></div>
<!-- Hold details panel (shows when hold selected) -->
<div id="hold-details" class="hidden mt-4 p-4 bg-base-200 rounded-lg">
<h3 class="font-semibold mb-3">Hold Details</h3>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<dt class="text-base-content/70">DID:</dt>
<dd id="hold-did" class="font-mono"></dd>
<dt class="text-base-content/70">Region:</dt>
<dd id="hold-region"></dd>
<dt class="text-base-content/70">Status:</dt>
<dd id="hold-status-badge"></dd>
<dt class="text-base-content/70">Your Access:</dt>
<dd id="hold-access"></dd>
</dl>
</div>
</section>
<!-- Storage Usage Section -->
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Stowage</h2>
<p class="text-base-content/70">Estimated storage usage on your default hold.</p>
<div id="storage-stats" hx-get="/api/storage" hx-trigger="tab:storage from:body once" hx-swap="innerHTML">
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading...</p>
</div>
</section>
<!-- Subscription Section -->
<div id="subscription-wrapper" hx-get="/api/subscription" hx-trigger="tab:storage from:body once" hx-swap="innerHTML">
<section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Subscription</h2>
<p class="text-base-content/70">Manage your storage tier and billing.</p>
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...</p>
</section>
</div>
</div>
<!-- WEBHOOKS TAB -->
<div id="tab-webhooks" class="settings-panel hidden space-y-6">
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<div>
<h2 class="text-xl font-semibold">Scan Webhooks</h2>
<p class="text-base-content/70 mt-1">Get HTTP notifications when vulnerability scans complete.</p>
<p class="text-base-content/70 mt-1">Get notified when vulnerability scans complete on any of your images.</p>
</div>
<div id="webhooks-content"
hx-get="/api/webhooks"
hx-trigger="tab:webhooks from:body once"
hx-swap="innerHTML">
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading webhooks...</p>
<div id="webhooks-content">
{{ template "webhooks_list" .WebhooksData }}
</div>
</section>
</div>
@@ -302,16 +215,12 @@
</div>
</div>
</div>
</main>
<script>
// Hold data from server (for details panel)
const holdData = {{ .HoldDataJSON }};
// Tab switching
(function() {
var validTabs = ['identity', 'devices', 'storage', 'webhooks', 'advanced'];
var validTabs = ['storage', 'devices', 'webhooks', 'advanced'];
function switchSettingsTab(tabId) {
// Hide all panels
@@ -356,8 +265,8 @@
document.addEventListener('DOMContentLoaded', function() {
// Read initial tab from hash
var hash = window.location.hash.replace('#', '') || 'identity';
if (validTabs.indexOf(hash) === -1) hash = 'identity';
var hash = window.location.hash.replace('#', '') || 'storage';
if (validTabs.indexOf(hash) === -1) hash = 'storage';
// Mobile tab click handlers
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
@@ -383,90 +292,13 @@
// Handle browser back/forward
window.addEventListener('hashchange', function() {
var hash = window.location.hash.replace('#', '') || 'identity';
var hash = window.location.hash.replace('#', '') || 'storage';
if (validTabs.indexOf(hash) !== -1) {
switchSettingsTab(hash);
}
});
})();
// Hold Selection and Details Display
document.addEventListener('DOMContentLoaded', function() {
const holdSelect = document.getElementById('default-hold');
const holdDetails = document.getElementById('hold-details');
const holdForm = document.getElementById('hold-form');
if (holdSelect) {
holdSelect.addEventListener('change', function() {
const selectedDID = this.value;
if (!selectedDID || !holdData[selectedDID]) {
holdDetails.style.display = 'none';
return;
}
const hold = holdData[selectedDID];
document.getElementById('hold-did').textContent = hold.did;
document.getElementById('hold-region').textContent = hold.region || 'Unknown';
// Set status badge
const statusEl = document.getElementById('hold-status-badge');
if (hold.status === 'offline') {
statusEl.innerHTML = '<span class="badge badge-sm badge-warning">Offline</span>';
} else if (hold.status === 'online') {
statusEl.innerHTML = '<span class="badge badge-sm badge-success">Online</span>';
} else {
statusEl.innerHTML = '<span class="text-base-content/60">Unknown</span>';
}
// Set access level with badge
const accessEl = document.getElementById('hold-access');
const accessLabel = {
'owner': 'Owner (Full Control)',
'crew': 'Crew Member',
'eligible': 'Open Registration',
'public': 'Public Access'
}[hold.membership] || hold.membership;
const badgeColor = {
'owner': 'badge-primary',
'crew': 'badge-secondary',
'eligible': 'badge-accent',
'public': 'badge-ghost'
}[hold.membership] || '';
accessEl.innerHTML = '<span class="badge badge-sm ' + badgeColor + '">' + accessLabel + '</span>';
// Show permissions for crew members
if (hold.membership === 'crew' && hold.permissions && hold.permissions.length > 0) {
accessEl.innerHTML += '<br><span class="text-xs text-base-content/60">Permissions: ' + hold.permissions.join(', ') + '</span>';
}
holdDetails.style.display = 'block';
});
// Re-fetch stowage and subscription when hold selection changes
holdSelect.addEventListener('change', function() {
var params = this.value ? '?hold_did=' + encodeURIComponent(this.value) : '';
htmx.ajax('GET', '/api/storage' + params, '#storage-stats');
htmx.ajax('GET', '/api/subscription' + params, {target: '#subscription-wrapper', swap: 'innerHTML'});
});
// Trigger on page load if a hold is already selected
if (holdSelect.value) {
holdSelect.dispatchEvent(new Event('change'));
}
}
// HTMX success handler - no icon reinitialization needed with SVG sprites
if (holdForm) {
holdForm.addEventListener('htmx:afterSwap', function(event) {
// SVG sprites don't need reinitialization
});
}
});
// Account Deletion JavaScript
(function() {
const deleteBtn = document.getElementById('delete-account-btn');
@@ -0,0 +1,32 @@
{{ define "hold_card" }}
<div class="card bg-base-100 shadow-sm">
<!-- Header -->
<div class="p-4 flex flex-wrap items-center gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-warning" title="Active hold">&#9733;</span>
<h3 class="font-semibold text-lg truncate">{{ .DisplayName }}</h3>
<span class="badge badge-sm badge-warning">Active</span>
{{ if eq .Membership "owner" }}<span class="badge badge-sm badge-primary">Owner</span>
{{ else }}<span class="badge badge-sm badge-secondary">Crew</span>{{ end }}
{{ if eq .Status "online" }}<span class="badge badge-sm badge-success gap-1">&#9679; Online</span>
{{ else if eq .Status "offline" }}<span class="badge badge-sm badge-error gap-1">&#9679; Offline</span>
{{ end }}
</div>
<code class="text-xs text-base-content/50 break-all">{{ .DID }}</code>
</div>
<div class="shrink-0">
</div>
</div>
<!-- Storage Stats (always visible, lazy-loaded) -->
<div class="px-4 pb-4">
<div id="storage-stats-active"
hx-get="/api/storage?hold_did={{ .DID | urlquery }}"
hx-trigger="tab:storage from:body once"
hx-swap="innerHTML">
<p class="flex items-center gap-2 text-sm text-base-content/50">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p>
</div>
</div>
</div>
{{ end }}
@@ -0,0 +1,36 @@
{{ define "hold_selector" }}
<div class="card bg-base-100 shadow-sm p-4">
<form hx-post="/api/profile/default-hold" hx-swap="none" class="flex items-center gap-3 flex-wrap">
<label class="text-sm font-medium whitespace-nowrap" for="hold-select">Active Hold:</label>
<select id="hold-select" name="hold_did" class="select select-bordered select-sm flex-1 min-w-0"
onchange="this.form.requestSubmit()">
{{ if not .ActiveHold }}
<option value="" selected>-- Select a hold --</option>
{{ end }}
<optgroup label="Your Holds">
{{ range .AllHolds }}
{{ if ne .Membership "eligible" }}
<option value="{{ .DID }}" {{ if .IsActive }}selected{{ end }}>
{{ .DisplayName }}{{ if eq .Membership "owner" }} (Owner){{ else }} (Crew){{ end }}{{ if .Region }} &middot; {{ .Region }}{{ end }}
</option>
{{ end }}
{{ end }}
</optgroup>
{{ range .AllHolds }}{{ if eq .Membership "eligible" }}
<optgroup label="Available Holds">
{{ range $.AllHolds }}
{{ if eq .Membership "eligible" }}
<option value="{{ .DID }}">
{{ .DisplayName }}{{ if .Region }} &middot; {{ .Region }}{{ end }} (Join)
</option>
{{ end }}
{{ end }}
</optgroup>
{{ break }}{{ end }}{{ end }}
</select>
<noscript><button type="submit" class="btn btn-sm btn-primary">Switch</button></noscript>
</form>
</div>
{{ end }}
@@ -0,0 +1,47 @@
{{ define "other_holds_table" }}
<div class="card bg-base-100 shadow-sm">
<div class="p-4 pb-2">
<h3 class="text-sm font-semibold text-base-content/70">Other Holds</h3>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Hold</th>
<th>Role</th>
<th class="text-center">Status</th>
<th class="text-right">Storage</th>
</tr>
</thead>
<tbody>
{{ range . }}
<tr>
<td>
<span class="font-medium">{{ .DisplayName }}</span>
</td>
<td>
{{ if eq .Membership "owner" }}<span class="badge badge-xs badge-primary">Owner</span>
{{ else }}<span class="badge badge-xs badge-secondary">Crew</span>{{ end }}
</td>
<td class="text-center">
{{ if eq .Status "online" }}<span class="text-success" title="Online">&#9679;</span>
{{ else if eq .Status "offline" }}<span class="text-error" title="Offline">&#9679;</span>
{{ else }}<span class="text-base-content/30" title="Unknown">&#9679;</span>
{{ end }}
</td>
<td class="text-right">
<span id="storage-compact-{{ sanitizeID .DID }}"
hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true"
hx-trigger="tab:storage from:body once"
hx-swap="innerHTML"
class="text-sm font-mono">
...
</span>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ end }}
@@ -5,12 +5,6 @@
<span class="text-base-content/60">Tier:</span>
<span class="badge badge-xs badge-{{ .Tier }} font-semibold">{{ .Tier }}</span>
</div>
{{ if .HasSupporterBadge }}
<div class="flex justify-between items-center">
<span class="text-base-content/60">Profile Badge:</span>
<span class="text-sm text-success flex items-center gap-1">{{ icon "badge-check" "size-4" }} Visible on your profile</span>
</div>
{{ end }}
{{ end }}
<div class="flex justify-between items-center">
<span class="text-base-content/60">Storage:</span>
@@ -1,65 +1,55 @@
{{ define "subscription_info" }}
{{ if .HideBilling }}
<!-- subscription: billing not available for this hold -->
{{ else }}
<section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Subscription</h2>
<p class="text-base-content/70">Manage your storage tier and billing.{{ if .HoldDisplayName }} Storage provided by <strong>{{ .HoldDisplayName }}</strong>.{{ end }}</p>
{{ if .Error }}
<div class="alert alert-error">
{{ icon "alert-circle" "size-5" }} {{ .Error }}
</div>
{{ else }}
<!-- Current Plan -->
<div class="bg-base-200 p-4 rounded-lg mb-4">
<div class="flex justify-between py-2 border-b border-base-300">
<span class="text-base-content/70">Current Tier:</span>
<span class="font-bold capitalize">{{ .CurrentTier }}</span>
</div>
{{ if and .CrewTier (ne .CrewTier .CurrentTier) }}
<div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1">
<span class="text-base-content/70">Crew Record Tier:</span>
<span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span>
</div>
{{ end }}
{{ if .SubscriptionID }}
<div class="flex justify-between py-2">
<span class="text-base-content/70">Billing:</span>
<span class="font-bold">{{ .BillingInterval }}</span>
</div>
<a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a>
{{ end }}
</div>
<!-- Available Tiers -->
{{ if .Tiers }}
<h3 class="font-semibold">Available Plans</h3>
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4">
{{ define "subscription_plans" }}
{{ if not .HideBilling }}
{{ if .Tiers }}
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h3 class="text-xl font-semibold">Available Plans</h3>
<div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4">
{{ range .Tiers }}
<div class="border rounded-lg p-5 bg-base-200 relative flex flex-col{{ if .IsCurrent }} border-primary border-2{{ else }} border-base-300{{ end }}">
{{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }}
<div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div>
<div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div>
{{ if .Description }}
<div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div>
<div class="text-lg font-bold capitalize">{{ .Name }}</div>
{{ if .Description }}<p class="text-sm text-base-content/60 mt-1">{{ .Description }}</p>{{ end }}
{{ if .Features }}
<ul class="text-sm text-base-content/60 mt-2 list-disc list-inside space-y-0.5">
{{ range .Features }}<li>{{ . }}</li>{{ end }}
</ul>
{{ end }}
<div class="flex-1"></div>
<div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div>
<div class="mt-4">
{{ if and .IsCurrent (not $.SubscriptionID) (or .PriceCentsMonthly .PriceCentsYearly) }}
{{/* Current tier with optional support pricing */}}
<div class="text-2xl font-bold text-success">Free</div>
{{ if .PriceMonthly }}<div class="text-sm text-base-content/60">{{ .PriceMonthly }} to support</div>
{{ else if .PriceYearly }}<div class="text-sm text-base-content/60">{{ .PriceYearly }} to support</div>{{ end }}
{{ else if .PriceMonthly }}
<div class="text-2xl font-bold">{{ .PriceMonthly }}</div>
{{ if .PriceYearly }}<div class="text-sm text-base-content/60">or {{ .PriceYearly }}</div>{{ end }}
{{ else if .PriceYearly }}
<div class="text-2xl font-bold">{{ .PriceYearly }}</div>
{{ else }}
<div class="text-2xl font-bold text-success">Free</div>
{{ end }}
</div>
{{ if not .IsCurrent }}
{{ if or .PriceCentsMonthly .PriceCentsYearly }}
{{ if $.SubscriptionID }}
<a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a>
<a href="/settings/subscription/portal" class="btn btn-primary w-full mt-3">Change Plan</a>
{{ else }}
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a>
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full mt-3">Upgrade</a>
{{ end }}
{{ end }}
{{ else if and (not $.SubscriptionID) (or .PriceCentsMonthly .PriceCentsYearly) }}
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-outline btn-primary w-full mt-3">Become a Supporter</a>
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{ if .SubscriptionID }}
<div class="flex justify-end">
<a href="/settings/subscription/portal" class="btn btn-outline btn-primary btn-sm">Manage Billing</a>
</div>
{{ end }}
</section>
{{ end }}
{{ end }}
{{ end }}
@@ -2,7 +2,7 @@
<div class="space-y-6">
<!-- Add Webhook Form -->
<form hx-post="/api/webhooks"
hx-target="#webhooks-content"
hx-target="#{{ .ContainerID }}"
hx-swap="innerHTML"
class="space-y-4 bg-base-200 rounded-lg p-4">
<h3 class="font-semibold">Add Webhook</h3>
@@ -79,13 +79,13 @@
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs btn-ghost"
onclick="testWebhook('{{ .Rkey }}')"
onclick="testWebhook('{{ .ID }}')"
title="Send test payload">
Test
</button>
<button class="btn btn-xs btn-error btn-ghost"
hx-delete="/api/webhooks/{{ .Rkey }}"
hx-target="#webhooks-content"
hx-delete="/api/webhooks/{{ .ID }}"
hx-target="#{{ $.ContainerID }}"
hx-swap="innerHTML"
hx-confirm="Delete this webhook?"
title="Delete webhook">
+2
View File
@@ -195,8 +195,10 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
// Replace special CSS selector characters with dashes
// e.g., "sha256:abc123" becomes "sha256-abc123"
// e.g., "v0.0.2" becomes "v0-0-2"
// e.g., "did:web:172.28.0.3%3A8080" becomes "did-web-172-28-0-3-3A8080"
s = strings.ReplaceAll(s, ":", "-")
s = strings.ReplaceAll(s, ".", "-")
s = strings.ReplaceAll(s, "%", "-")
return s
},
+234
View File
@@ -0,0 +1,234 @@
package webhooks
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
)
// Dispatcher handles webhook delivery for scan notifications.
// It reads webhooks from the appview DB and delivers payloads
// with Discord/Slack formatting and HMAC signing.
type Dispatcher struct {
db db.DBTX
meta atproto.AppviewMetadata
}
// NewDispatcher creates a new webhook dispatcher
func NewDispatcher(database db.DBTX, meta atproto.AppviewMetadata) *Dispatcher {
return &Dispatcher{
db: database,
meta: meta,
}
}
// DispatchForScan fires matching webhooks after a scan record arrives via Jetstream.
// previousScan is nil for first-time scans. userHandle is used for payload enrichment.
func (d *Dispatcher) DispatchForScan(ctx context.Context, scan, previousScan *db.Scan, userHandle, tag, holdEndpoint string) {
webhooks, err := db.GetWebhooksForUser(d.db, scan.UserDID)
if err != nil || len(webhooks) == 0 {
return
}
isFirst := previousScan == nil
isChanged := previousScan != nil && vulnCountsChanged(scan, previousScan)
scanInfo := WebhookScanInfo{
ScannedAt: scan.ScannedAt.Format(time.RFC3339),
ScannerVersion: scan.ScannerVersion,
Vulnerabilities: WebhookVulnCounts{
Critical: scan.Critical,
High: scan.High,
Medium: scan.Medium,
Low: scan.Low,
Total: scan.Total,
},
}
manifestInfo := WebhookManifestInfo{
Digest: scan.ManifestDigest,
Repository: scan.Repository,
Tag: tag,
UserDID: scan.UserDID,
UserHandle: userHandle,
}
for _, wh := range webhooks {
// Check each trigger condition against bitmask
var triggers []string
if wh.Triggers&TriggerFirst != 0 && isFirst {
triggers = append(triggers, "scan:first")
}
if wh.Triggers&TriggerAll != 0 {
triggers = append(triggers, "scan:all")
}
if wh.Triggers&TriggerChanged != 0 && isChanged {
triggers = append(triggers, "scan:changed")
}
for _, trigger := range triggers {
payload := WebhookPayload{
Trigger: trigger,
HoldDID: scan.HoldDID,
HoldEndpoint: holdEndpoint,
Manifest: manifestInfo,
Scan: scanInfo,
}
// Include previous counts for scan:changed
if trigger == "scan:changed" && previousScan != nil {
payload.Previous = &WebhookVulnCounts{
Critical: previousScan.Critical,
High: previousScan.High,
Medium: previousScan.Medium,
Low: previousScan.Low,
Total: previousScan.Total,
}
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
slog.Error("Failed to marshal webhook payload", "error", err)
continue
}
go d.deliverWithRetry(wh.URL, wh.Secret, payloadBytes)
}
}
}
// DeliverTest sends a test payload to a specific webhook (synchronous, single attempt)
func (d *Dispatcher) DeliverTest(ctx context.Context, webhookID, userDID, userHandle string) (bool, error) {
wh, err := db.GetWebhookByID(d.db, webhookID)
if err != nil {
return false, err
}
if wh.UserDID != userDID {
return false, fmt.Errorf("unauthorized")
}
// Randomize vulnerability counts so each test shows a different severity color
critical := rand.IntN(3)
high := rand.IntN(5)
medium := rand.IntN(8)
low := rand.IntN(10)
total := critical + high + medium + low
payload := WebhookPayload{
Trigger: "test",
Manifest: WebhookManifestInfo{
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
Repository: "test-repo",
Tag: "latest",
UserDID: userDID,
UserHandle: userHandle,
},
Scan: WebhookScanInfo{
ScannedAt: time.Now().Format(time.RFC3339),
ScannerVersion: "atcr-scanner-v1.0.0",
Vulnerabilities: WebhookVulnCounts{
Critical: critical, High: high, Medium: medium, Low: low, Total: total,
},
},
}
payloadBytes, _ := json.Marshal(payload)
success := d.attemptDelivery(wh.URL, wh.Secret, payloadBytes)
return success, nil
}
// deliverWithRetry attempts to deliver a webhook with exponential backoff
func (d *Dispatcher) deliverWithRetry(webhookURL, secret string, payload []byte) {
delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute}
for attempt, delay := range delays {
if attempt > 0 {
time.Sleep(delay)
}
if d.attemptDelivery(webhookURL, secret, payload) {
return
}
}
slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL))
}
// attemptDelivery sends a single webhook HTTP POST
func (d *Dispatcher) attemptDelivery(webhookURL, secret string, payload []byte) bool {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Reformat payload for platform-specific webhook APIs
sendPayload := payload
if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) {
var p WebhookPayload
if err := json.Unmarshal(payload, &p); err == nil {
var formatted []byte
var fmtErr error
if isDiscordWebhook(webhookURL) {
formatted, fmtErr = formatDiscordPayload(p, d.meta)
} else {
formatted, fmtErr = formatSlackPayload(p, d.meta)
}
if fmtErr == nil {
sendPayload = formatted
}
}
}
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(sendPayload)))
if err != nil {
slog.Warn("Failed to create webhook request", "error", err)
return false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", d.meta.ClientShortName+"-Webhook/1.0")
// HMAC signing if secret is set (signs the actual payload sent)
if secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(sendPayload)
sig := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Webhook-Signature-256", "sha256="+sig)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode)
return true
}
// Read response body for debugging
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
slog.Warn("Webhook delivery got non-2xx response",
"url", maskURL(webhookURL),
"status", resp.StatusCode,
"body", string(body))
return false
}
// vulnCountsChanged checks if vulnerability counts differ between scans
func vulnCountsChanged(current, previous *db.Scan) bool {
return current.Critical != previous.Critical ||
current.High != previous.High ||
current.Medium != previous.Medium ||
current.Low != previous.Low
}
+184
View File
@@ -0,0 +1,184 @@
package webhooks
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"atcr.io/pkg/atproto"
)
// maskURL masks a URL for display (shows scheme + host, hides path/query)
func maskURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
if len(rawURL) > 30 {
return rawURL[:30] + "***"
}
return rawURL
}
masked := u.Scheme + "://" + u.Host
if u.Path != "" && u.Path != "/" {
masked += "/***"
}
return masked
}
// isDiscordWebhook checks if the URL points to a Discord webhook endpoint
func isDiscordWebhook(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com")
}
// isSlackWebhook checks if the URL points to a Slack webhook endpoint
func isSlackWebhook(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return u.Host == "hooks.slack.com"
}
// webhookSeverityColor returns a color int based on the highest severity present
func webhookSeverityColor(vulns WebhookVulnCounts) int {
switch {
case vulns.Critical > 0:
return 0xED4245 // red
case vulns.High > 0:
return 0xFFA500 // orange
case vulns.Medium > 0:
return 0xFEE75C // yellow
case vulns.Low > 0:
return 0x57F287 // green
default:
return 0x95A5A6 // grey
}
}
// webhookSeverityHex returns a hex color string (e.g., "#ED4245")
func webhookSeverityHex(vulns WebhookVulnCounts) string {
return fmt.Sprintf("#%06X", webhookSeverityColor(vulns))
}
// formatVulnDescription builds a vulnerability summary with colored square emojis
func formatVulnDescription(v WebhookVulnCounts, digest string) string {
var lines []string
if len(digest) > 19 {
lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"..."))
}
if v.Total == 0 {
lines = append(lines, "🟩 No vulnerabilities found")
} else {
if v.Critical > 0 {
lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical))
}
if v.High > 0 {
lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High))
}
if v.Medium > 0 {
lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium))
}
if v.Low > 0 {
lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low))
}
}
return strings.Join(lines, "\n")
}
// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format
func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
appviewURL := meta.BaseURL
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest)
// Add previous counts for scan:changed
if p.Trigger == "scan:changed" && p.Previous != nil {
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
}
embed := map[string]any{
"title": title,
"url": appviewURL,
"description": description,
"color": webhookSeverityColor(p.Scan.Vulnerabilities),
"footer": map[string]string{
"text": meta.ClientShortName,
"icon_url": meta.FaviconURL,
},
"timestamp": p.Scan.ScannedAt,
}
// Add author, repo link, and OG image when handle is available
if p.Manifest.UserHandle != "" {
embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
embed["author"] = map[string]string{
"name": p.Manifest.UserHandle,
"url": appviewURL + "/u/" + p.Manifest.UserHandle,
}
embed["image"] = map[string]string{
"url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository),
}
} else {
embed["image"] = map[string]string{
"url": appviewURL + "/og/home",
}
}
payload := map[string]any{
"username": meta.ClientShortName,
"avatar_url": meta.FaviconURL,
"embeds": []any{embed},
}
return json.Marshal(payload)
}
// formatSlackPayload wraps an ATCR webhook payload in Slack's message format
func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
appviewURL := meta.BaseURL
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
v := p.Scan.Vulnerabilities
fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low",
title, v.Critical, v.High, v.Medium, v.Low)
description := formatVulnDescription(v, p.Manifest.Digest)
// Add previous counts for scan:changed
if p.Trigger == "scan:changed" && p.Previous != nil {
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
}
attachment := map[string]any{
"fallback": fallback,
"color": webhookSeverityHex(v),
"title": title,
"text": description,
"footer": meta.ClientShortName,
"footer_icon": meta.FaviconURL,
"ts": p.Scan.ScannedAt,
}
// Add repo link when handle is available
if p.Manifest.UserHandle != "" {
attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
attachment["author_name"] = p.Manifest.UserHandle
attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle
}
payload := map[string]any{
"text": fallback,
"attachments": []any{attachment},
}
return json.Marshal(payload)
}
+44
View File
@@ -0,0 +1,44 @@
// Package webhooks provides webhook dispatch and formatting for scan notifications.
package webhooks
// Webhook trigger bitmask constants
const (
TriggerFirst = 0x01 // First-time scan (no previous scan record)
TriggerAll = 0x02 // Every scan completion
TriggerChanged = 0x04 // Vulnerability counts changed from previous
)
// WebhookPayload is the JSON body sent to webhook URLs
type WebhookPayload struct {
Trigger string `json:"trigger"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Manifest WebhookManifestInfo `json:"manifest"`
Scan WebhookScanInfo `json:"scan"`
Previous *WebhookVulnCounts `json:"previous"`
}
// WebhookManifestInfo describes the scanned manifest
type WebhookManifestInfo struct {
Digest string `json:"digest"`
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle,omitempty"`
}
// WebhookScanInfo describes the scan results
type WebhookScanInfo struct {
ScannedAt string `json:"scannedAt"`
ScannerVersion string `json:"scannerVersion"`
Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"`
}
// WebhookVulnCounts contains vulnerability counts by severity
type WebhookVulnCounts struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Total int `json:"total"`
}
+2 -298
View File
@@ -377,7 +377,7 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
fieldCount := 9
fieldCount := 8
if t.Region == "" {
fieldCount--
@@ -387,10 +387,6 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
fieldCount--
}
if t.SupporterBadgeTiers == nil {
fieldCount--
}
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
}
@@ -563,42 +559,6 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
if err := cbg.WriteBool(w, t.EnableBlueskyPosts); err != nil {
return err
}
// t.SupporterBadgeTiers ([]string) (slice)
if t.SupporterBadgeTiers != nil {
if len("supporterBadgeTiers") > 8192 {
return xerrors.Errorf("Value in field \"supporterBadgeTiers\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("supporterBadgeTiers"))); err != nil {
return err
}
if _, err := cw.WriteString(string("supporterBadgeTiers")); err != nil {
return err
}
if len(t.SupporterBadgeTiers) > 8192 {
return xerrors.Errorf("Slice value in field t.SupporterBadgeTiers was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.SupporterBadgeTiers))); err != nil {
return err
}
for _, v := range t.SupporterBadgeTiers {
if len(v) > 8192 {
return xerrors.Errorf("Value in field v was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
return err
}
if _, err := cw.WriteString(string(v)); err != nil {
return err
}
}
}
return nil
}
@@ -627,7 +587,7 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
n := extra
nameBuf := make([]byte, 19)
nameBuf := make([]byte, 18)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
if err != nil {
@@ -752,46 +712,6 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
default:
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
}
// t.SupporterBadgeTiers ([]string) (slice)
case "supporterBadgeTiers":
maj, extra, err = cr.ReadHeader()
if err != nil {
return err
}
if extra > 8192 {
return fmt.Errorf("t.SupporterBadgeTiers: array too large (%d)", extra)
}
if maj != cbg.MajArray {
return fmt.Errorf("expected cbor array")
}
if extra > 0 {
t.SupporterBadgeTiers = make([]string, extra)
}
for i := 0; i < int(extra); i++ {
{
var maj byte
var extra uint64
var err error
_ = maj
_ = extra
_ = err
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.SupporterBadgeTiers[i] = string(sval)
}
}
}
default:
// Field doesn't exist on this type, so ignore it
@@ -2505,219 +2425,3 @@ func (t *ScanRecord) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
func (t *HoldWebhookRecord) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
}
cw := cbg.NewCborWriter(w)
if _, err := cw.Write([]byte{164}); err != nil {
return err
}
// t.Type (string) (string)
if len("$type") > 8192 {
return xerrors.Errorf("Value in field \"$type\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
return err
}
if _, err := cw.WriteString(string("$type")); err != nil {
return err
}
if len(t.Type) > 8192 {
return xerrors.Errorf("Value in field t.Type was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Type)); err != nil {
return err
}
// t.UserDID (string) (string)
if len("userDid") > 8192 {
return xerrors.Errorf("Value in field \"userDid\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("userDid"))); err != nil {
return err
}
if _, err := cw.WriteString(string("userDid")); err != nil {
return err
}
if len(t.UserDID) > 8192 {
return xerrors.Errorf("Value in field t.UserDID was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.UserDID)); err != nil {
return err
}
// t.Triggers (int64) (int64)
if len("triggers") > 8192 {
return xerrors.Errorf("Value in field \"triggers\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("triggers"))); err != nil {
return err
}
if _, err := cw.WriteString(string("triggers")); err != nil {
return err
}
if t.Triggers >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Triggers)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Triggers-1)); err != nil {
return err
}
}
// t.CreatedAt (string) (string)
if len("createdAt") > 8192 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
return err
}
if _, err := cw.WriteString(string("createdAt")); err != nil {
return err
}
if len(t.CreatedAt) > 8192 {
return xerrors.Errorf("Value in field t.CreatedAt was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
}
return nil
}
func (t *HoldWebhookRecord) UnmarshalCBOR(r io.Reader) (err error) {
*t = HoldWebhookRecord{}
cr := cbg.NewCborReader(r)
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
defer func() {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
}()
if maj != cbg.MajMap {
return fmt.Errorf("cbor input should be of type map")
}
if extra > cbg.MaxLength {
return fmt.Errorf("HoldWebhookRecord: map struct too large (%d)", extra)
}
n := extra
nameBuf := make([]byte, 9)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
if err != nil {
return err
}
if !ok {
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
return err
}
continue
}
switch string(nameBuf[:nameLen]) {
// t.Type (string) (string)
case "$type":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Type = string(sval)
}
// t.UserDID (string) (string)
case "userDid":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.UserDID = string(sval)
}
// t.Triggers (int64) (int64)
case "triggers":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.Triggers = int64(extraI)
}
// t.CreatedAt (string) (string)
case "createdAt":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.CreatedAt = string(sval)
}
default:
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
return err
}
}
}
return nil
}
+16 -26
View File
@@ -85,32 +85,6 @@ const (
// Auth: Shared secret (query param or header)
// Response: Stream of scan job events (JSON)
HoldSubscribeScanJobs = "/xrpc/io.atcr.hold.subscribeScanJobs"
// HoldListWebhooks lists webhook configurations for a user.
// Method: GET
// Query: userDid={did}
// Response: {webhooks: [...], limits: {max, allTriggers}}
HoldListWebhooks = "/xrpc/io.atcr.hold.listWebhooks"
// HoldAddWebhook creates a new webhook configuration.
// Method: POST
// Request: {userDid, url, secret, triggers}
// Response: {rkey, cid}
HoldAddWebhook = "/xrpc/io.atcr.hold.addWebhook"
// HoldDeleteWebhook deletes a webhook configuration.
// Method: POST
// Request: {rkey}
// Response: {success: true}
HoldDeleteWebhook = "/xrpc/io.atcr.hold.deleteWebhook"
// HoldTestWebhook sends a test payload to a webhook.
// Method: POST
// Request: {rkey}
// Response: {statusCode, success}
HoldTestWebhook = "/xrpc/io.atcr.hold.testWebhook"
// Future: HoldDelegateAccess = "/xrpc/io.atcr.hold.delegateAccess"
)
// ATProto sync endpoints (com.atproto.sync.*)
@@ -269,6 +243,22 @@ const (
IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle"
)
// Hold billing/tier endpoints (io.atcr.hold.*)
//
// These endpoints manage billing tiers on hold services.
const (
// HoldUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview.
// Method: POST
// Request: {"userDid": "did:...", "tierRank": 0}
// Response: {"tierName": "deckhand"}
HoldUpdateCrewTier = "/xrpc/io.atcr.hold.updateCrewTier"
// HoldListTiers lists the hold's available tiers with storage quotas and features.
// Method: GET
// Response: {"tiers": [{"name": "deckhand", "quotaBytes": 5368709120, "quotaFormatted": "5.0 GB", "scanOnPush": false}]}
HoldListTiers = "/xrpc/io.atcr.hold.listTiers"
)
// Appview metadata endpoint (io.atcr.*)
const (
// AppviewGetMetadata returns appview branding and configuration metadata.
-1
View File
@@ -33,7 +33,6 @@ func main() {
atproto.TangledProfileRecord{},
atproto.StatsRecord{},
atproto.ScanRecord{},
atproto.HoldWebhookRecord{},
); err != nil {
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
os.Exit(1)
+8 -65
View File
@@ -64,12 +64,6 @@ const (
// RepoPageCollection is the collection name for repository page metadata
// Stored in user's PDS with rkey = repository name
RepoPageCollection = "io.atcr.repo.page"
// SailorWebhookCollection is the collection name for webhook configs in user's PDS
SailorWebhookCollection = "io.atcr.sailor.webhook"
// WebhookCollection is the collection name for webhook records in hold's embedded PDS
WebhookCollection = "io.atcr.hold.webhook"
)
// ManifestRecord represents a container image manifest stored in ATProto
@@ -667,15 +661,14 @@ func (t *TagRecord) GetManifestDigest() (string, error) {
// Stored in the hold's embedded PDS to identify the hold owner and settings
// Uses CBOR encoding for efficient storage in hold's carstore
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
}
// CrewRecord represents a crew member in the hold
@@ -822,56 +815,6 @@ func ScanRecordKey(manifestDigest string) string {
return strings.TrimPrefix(manifestDigest, "sha256:")
}
// Webhook trigger bitmask constants
const (
TriggerFirst = 0x01 // First-time scan (no previous scan record)
TriggerAll = 0x02 // Every scan completion
TriggerChanged = 0x04 // Vulnerability counts changed from previous
)
// SailorWebhookRecord represents a webhook config in the user's PDS
// Links to a private HoldWebhookRecord via privateCid
type SailorWebhookRecord struct {
Type string `json:"$type"`
HoldDID string `json:"holdDid"`
Triggers int `json:"triggers"`
PrivateCID string `json:"privateCid"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// NewSailorWebhookRecord creates a new sailor webhook record
func NewSailorWebhookRecord(holdDID string, triggers int, privateCID string) *SailorWebhookRecord {
now := time.Now().Format(time.RFC3339)
return &SailorWebhookRecord{
Type: SailorWebhookCollection,
HoldDID: holdDID,
Triggers: triggers,
PrivateCID: privateCID,
CreatedAt: now,
UpdatedAt: now,
}
}
// HoldWebhookRecord represents a webhook record in the hold's embedded PDS
// The actual URL and secret are stored in SQLite (never in ATProto records)
type HoldWebhookRecord struct {
Type string `json:"$type" cborgen:"$type"`
UserDID string `json:"userDid" cborgen:"userDid"`
Triggers int64 `json:"triggers" cborgen:"triggers"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
}
// NewHoldWebhookRecord creates a new hold webhook record
func NewHoldWebhookRecord(userDID string, triggers int) *HoldWebhookRecord {
return &HoldWebhookRecord{
Type: WebhookCollection,
UserDID: userDID,
Triggers: int64(triggers),
CreatedAt: time.Now().Format(time.RFC3339),
}
}
// TangledProfileRecord represents a Tangled profile for the hold
// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
// Stored in the hold's embedded PDS
+77
View File
@@ -0,0 +1,77 @@
package auth
import (
"crypto/ecdh"
"crypto/ecdsa"
"crypto/x509"
"fmt"
"time"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/golang-jwt/jwt/v5"
)
// CreateAppviewServiceToken creates a short-lived ES256 JWT for appview→hold communication.
// The token authenticates the appview when calling hold XRPC endpoints like updateCrewTier.
//
// Claims:
// - iss: appview DID (e.g. did:web:atcr.io)
// - aud: hold DID (e.g. did:web:hold01.atcr.io)
// - sub: user DID being acted upon
// - exp: now + 60s
// - iat: now
func CreateAppviewServiceToken(privateKey *atcrypto.PrivateKeyP256, appviewDID, holdDID, userDID string) (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: appviewDID,
Audience: jwt.ClaimStrings{holdDID},
Subject: userDID,
ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
ecKey, err := P256ToECDSA(privateKey)
if err != nil {
return "", fmt.Errorf("failed to extract ECDSA key: %w", err)
}
signed, err := token.SignedString(ecKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return signed, nil
}
// P256ToECDSA converts an atcrypto P-256 private key to a stdlib *ecdsa.PrivateKey.
// This is needed because golang-jwt requires stdlib crypto types, while atcrypto
// wraps them in its own types. We re-parse via PKCS8 encoding round-trip.
func P256ToECDSA(key *atcrypto.PrivateKeyP256) (*ecdsa.PrivateKey, error) {
rawBytes := key.Bytes() // 32-byte raw scalar
// Parse raw bytes as ecdh key, then convert via PKCS8 round-trip (same as atcrypto does)
ecdhKey, err := ecdh.P256().NewPrivateKey(rawBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse P-256 raw bytes: %w", err)
}
pkcs8, err := x509.MarshalPKCS8PrivateKey(ecdhKey)
if err != nil {
return nil, fmt.Errorf("failed to marshal PKCS8: %w", err)
}
parsed, err := x509.ParsePKCS8PrivateKey(pkcs8)
if err != nil {
return nil, fmt.Errorf("failed to parse PKCS8: %w", err)
}
ecdsaKey, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("parsed key is not ECDSA")
}
return ecdsaKey, nil
}
+730
View File
@@ -0,0 +1,730 @@
//go:build billing
package billing
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"atcr.io/pkg/appview/holdclient"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
"github.com/stripe/stripe-go/v84/checkout/session"
"github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/price"
"github.com/stripe/stripe-go/v84/subscription"
"github.com/stripe/stripe-go/v84/webhook"
)
// Manager handles Stripe billing and pushes tier updates to managed holds.
type Manager struct {
cfg *Config
privateKey *atcrypto.PrivateKeyP256
appviewDID string
managedHolds []string
baseURL string
stripeKey string
webhookSecret string
// Captain checker: bypasses billing for hold owners
captainChecker CaptainChecker
// Customer cache: DID → Stripe customer
customerCache map[string]*cachedCustomer
customerCacheMu sync.RWMutex
// Price cache: Stripe price ID → unit amount in cents
priceCache map[string]*cachedPrice
priceCacheMu sync.RWMutex
// Hold tier cache: holdDID → tier list
holdTierCache map[string]*cachedHoldTiers
holdTierCacheMu sync.RWMutex
}
type cachedHoldTiers struct {
tiers []holdclient.HoldTierInfo
expiresAt time.Time
}
type cachedCustomer struct {
customer *stripe.Customer
expiresAt time.Time
}
type cachedPrice struct {
unitAmount int64
expiresAt time.Time
}
const customerCacheTTL = 10 * time.Minute
const priceCacheTTL = 1 * time.Hour
// New creates a new billing manager with Stripe integration.
// Env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take precedence over config values.
func New(cfg *Config, privateKey *atcrypto.PrivateKeyP256, appviewDID string, managedHolds []string, baseURL string) *Manager {
stripeKey := os.Getenv("STRIPE_SECRET_KEY")
if stripeKey == "" {
stripeKey = cfg.StripeSecretKey
}
if stripeKey != "" {
stripe.Key = stripeKey
}
webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
if webhookSecret == "" {
webhookSecret = cfg.WebhookSecret
}
return &Manager{
cfg: cfg,
privateKey: privateKey,
appviewDID: appviewDID,
managedHolds: managedHolds,
baseURL: baseURL,
stripeKey: stripeKey,
webhookSecret: webhookSecret,
customerCache: make(map[string]*cachedCustomer),
priceCache: make(map[string]*cachedPrice),
holdTierCache: make(map[string]*cachedHoldTiers),
}
}
// SetCaptainChecker sets a callback that checks if a user is a hold captain.
// Captains bypass all billing feature gates.
func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
m.captainChecker = fn
}
func (m *Manager) isCaptain(userDID string) bool {
return m.captainChecker != nil && userDID != "" && m.captainChecker(userDID)
}
// Enabled returns true if billing is properly configured.
func (m *Manager) Enabled() bool {
return m.cfg != nil && m.stripeKey != "" && len(m.cfg.Tiers) > 0
}
// GetWebhookLimits returns webhook limits for a user based on their subscription tier.
// Returns (maxWebhooks, allTriggers). Defaults to the lowest tier's limits.
// Hold captains get unlimited webhooks with all triggers.
func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
if m.isCaptain(userDID) {
return -1, true // unlimited
}
if !m.Enabled() {
return 1, false
}
info, err := m.GetSubscriptionInfo(userDID)
if err != nil || info == nil {
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
}
rank := info.TierRank
if rank >= 0 && rank < len(m.cfg.Tiers) {
return m.cfg.Tiers[rank].MaxWebhooks, m.cfg.Tiers[rank].WebhookAllTriggers
}
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
}
// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription.
// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise.
// Hold captains get a "Captain" badge.
func (m *Manager) GetSupporterBadge(userDID string) string {
if m.isCaptain(userDID) {
return "Captain"
}
if !m.Enabled() {
return ""
}
info, err := m.GetSubscriptionInfo(userDID)
if err != nil || info == nil {
return ""
}
for _, tier := range info.Tiers {
if tier.ID == info.CurrentTier && tier.SupporterBadge {
return info.CurrentTier
}
}
return ""
}
// GetSubscriptionInfo returns subscription and tier information for a user.
// Hold captains see a special "Captain" tier with all features unlocked.
func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
if m.isCaptain(userDID) {
return &SubscriptionInfo{
UserDID: userDID,
CurrentTier: "Captain",
TierRank: -1, // above all configured tiers
Tiers: []TierInfo{{
ID: "Captain",
Name: "Captain",
Description: "Hold operator",
Features: []string{"Unlimited storage", "Unlimited webhooks", "All webhook triggers", "Scan on push"},
Rank: -1,
MaxWebhooks: -1,
WebhookAllTriggers: true,
SupporterBadge: true,
IsCurrent: true,
}},
}, nil
}
if !m.Enabled() {
return nil, ErrBillingDisabled
}
info := &SubscriptionInfo{
UserDID: userDID,
PaymentsEnabled: true,
CurrentTier: m.cfg.Tiers[0].Name, // default to lowest
TierRank: 0,
}
// Build tier list with live Stripe prices
info.Tiers = make([]TierInfo, len(m.cfg.Tiers))
for i, tier := range m.cfg.Tiers {
// Dynamic features: hold-derived first, then webhook limits, then static config
features := m.aggregateHoldFeatures(i)
features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...)
if tier.SupporterBadge {
features = append(features, "Supporter badge")
}
features = append(features, tier.Features...)
info.Tiers[i] = TierInfo{
ID: tier.Name,
Name: tier.Name,
Description: tier.Description,
Features: features,
Rank: i,
MaxWebhooks: tier.MaxWebhooks,
WebhookAllTriggers: tier.WebhookAllTriggers,
SupporterBadge: tier.SupporterBadge,
}
if tier.StripePriceMonthly != "" {
if amount, err := m.fetchPrice(tier.StripePriceMonthly); err == nil {
info.Tiers[i].PriceCentsMonthly = int(amount)
}
}
if tier.StripePriceYearly != "" {
if amount, err := m.fetchPrice(tier.StripePriceYearly); err == nil {
info.Tiers[i].PriceCentsYearly = int(amount)
}
}
}
if userDID == "" {
return info, nil
}
// Find Stripe customer for this user
cust, err := m.findCustomerByDID(userDID)
if err != nil {
slog.Debug("No Stripe customer found", "userDID", userDID, "error", err)
return info, nil
}
info.CustomerID = cust.ID
// Find active subscription
params := &stripe.SubscriptionListParams{}
params.Filters.AddFilter("customer", "", cust.ID)
params.Filters.AddFilter("status", "", "active")
iter := subscription.List(params)
for iter.Next() {
sub := iter.Subscription()
info.SubscriptionID = sub.ID
if sub.Items != nil && len(sub.Items.Data) > 0 {
priceID := sub.Items.Data[0].Price.ID
tierName, tierRank := m.cfg.GetTierByPriceID(priceID)
if tierName != "" {
info.CurrentTier = tierName
info.TierRank = tierRank
}
if sub.Items.Data[0].Price.Recurring != nil {
switch sub.Items.Data[0].Price.Recurring.Interval {
case stripe.PriceRecurringIntervalMonth:
info.BillingInterval = "monthly"
case stripe.PriceRecurringIntervalYear:
info.BillingInterval = "yearly"
}
}
}
break
}
// Mark current tier
for i := range info.Tiers {
info.Tiers[i].IsCurrent = info.Tiers[i].ID == info.CurrentTier
}
return info, nil
}
// CreateCheckoutSession creates a Stripe checkout session for a subscription.
func (m *Manager) CreateCheckoutSession(r *http.Request, userDID, userHandle string, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
// Find the tier config
rank := m.cfg.TierRank(req.Tier)
if rank < 0 {
return nil, fmt.Errorf("unknown tier: %s", req.Tier)
}
tierCfg := m.cfg.Tiers[rank]
// Determine price ID: prefer monthly so Stripe upsell can offer yearly toggle,
// fall back to yearly if no monthly price exists.
var priceID string
if req.Interval == "yearly" && tierCfg.StripePriceYearly != "" {
priceID = tierCfg.StripePriceYearly
} else if tierCfg.StripePriceMonthly != "" {
priceID = tierCfg.StripePriceMonthly
} else if tierCfg.StripePriceYearly != "" {
priceID = tierCfg.StripePriceYearly
}
if priceID == "" {
return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
}
// Get or create Stripe customer
cust, err := m.getOrCreateCustomer(userDID, userHandle)
if err != nil {
return nil, fmt.Errorf("failed to get/create customer: %w", err)
}
// Build success/cancel URLs
successURL := strings.ReplaceAll(m.cfg.SuccessURL, "{base_url}", m.baseURL)
cancelURL := strings.ReplaceAll(m.cfg.CancelURL, "{base_url}", m.baseURL)
params := &stripe.CheckoutSessionParams{
Customer: stripe.String(cust.ID),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(successURL),
CancelURL: stripe.String(cancelURL),
}
s, err := session.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create checkout session: %w", err)
}
return &CheckoutSessionResponse{
CheckoutURL: s.URL,
SessionID: s.ID,
}, nil
}
// GetBillingPortalURL creates a Stripe billing portal session.
func (m *Manager) GetBillingPortalURL(userDID, returnURL string) (*BillingPortalResponse, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
cust, err := m.findCustomerByDID(userDID)
if err != nil {
return nil, fmt.Errorf("no billing account found")
}
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(cust.ID),
ReturnURL: stripe.String(returnURL),
}
s, err := portalsession.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create portal session: %w", err)
}
return &BillingPortalResponse{PortalURL: s.URL}, nil
}
// HandleWebhook processes a Stripe webhook event.
// On subscription changes, it pushes tier updates to all managed holds.
func (m *Manager) HandleWebhook(r *http.Request) error {
if !m.Enabled() {
return ErrBillingDisabled
}
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("failed to read webhook body: %w", err)
}
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
if err != nil {
return fmt.Errorf("webhook signature verification failed: %w", err)
}
switch event.Type {
case "checkout.session.completed":
m.handleCheckoutCompleted(event)
case "customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed":
m.handleSubscriptionChange(event)
default:
slog.Debug("Ignoring Stripe event", "type", event.Type)
}
return nil
}
// handleCheckoutCompleted processes a checkout.session.completed event.
func (m *Manager) handleCheckoutCompleted(event stripe.Event) {
var cs stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &cs); err != nil {
slog.Error("Failed to parse checkout session", "error", err)
return
}
slog.Info("Checkout completed", "customerID", cs.Customer.ID, "subscriptionID", cs.Subscription.ID)
// The subscription.created event will handle the tier update
}
// handleSubscriptionChange processes subscription lifecycle events.
func (m *Manager) handleSubscriptionChange(event stripe.Event) {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
slog.Error("Failed to parse subscription", "error", err)
return
}
// Get user DID from customer metadata
userDID := m.getCustomerDID(sub.Customer.ID)
if userDID == "" {
slog.Warn("No user DID found for Stripe customer", "customerID", sub.Customer.ID)
return
}
// Determine new tier from subscription
var tierName string
var tierRank int
switch sub.Status {
case stripe.SubscriptionStatusActive:
if sub.Items != nil && len(sub.Items.Data) > 0 {
priceID := sub.Items.Data[0].Price.ID
tierName, tierRank = m.cfg.GetTierByPriceID(priceID)
}
case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusPaused:
// Revert to free tier (rank 0)
tierName = m.cfg.Tiers[0].Name
tierRank = 0
default:
slog.Debug("Ignoring subscription status", "status", sub.Status)
return
}
if tierName == "" {
slog.Warn("Could not resolve tier from subscription", "priceID", sub.Items.Data[0].Price.ID)
return
}
slog.Info("Pushing tier update to managed holds",
"userDID", userDID,
"tierName", tierName,
"tierRank", tierRank,
"event", event.Type,
)
// Push tier update to all managed holds
go holdclient.UpdateCrewTierOnAllHolds(
context.Background(),
m.managedHolds,
userDID,
tierRank,
m.privateKey,
m.appviewDID,
)
// Invalidate customer cache
m.customerCacheMu.Lock()
delete(m.customerCache, userDID)
m.customerCacheMu.Unlock()
}
// getOrCreateCustomer finds or creates a Stripe customer for a DID.
func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) {
// Check cache
m.customerCacheMu.RLock()
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
m.customerCacheMu.RUnlock()
return cached.customer, nil
}
m.customerCacheMu.RUnlock()
// Search Stripe
cust, err := m.findCustomerByDID(userDID)
if err == nil {
m.cacheCustomer(userDID, cust)
return cust, nil
}
// Create new customer
params := &stripe.CustomerParams{
Params: stripe.Params{
Metadata: map[string]string{
"user_did": userDID,
},
},
}
if userHandle != "" {
params.Name = stripe.String(userHandle)
}
cust, err = customer.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create Stripe customer: %w", err)
}
m.cacheCustomer(userDID, cust)
return cust, nil
}
// findCustomerByDID searches Stripe for a customer with matching DID metadata.
func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
params := &stripe.CustomerSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
},
}
iter := customer.Search(params)
for iter.Next() {
return iter.Customer(), nil
}
return nil, fmt.Errorf("customer not found for DID %s", userDID)
}
// getCustomerDID retrieves the user DID from a Stripe customer's metadata.
func (m *Manager) getCustomerDID(customerID string) string {
cust, err := customer.Get(customerID, nil)
if err != nil {
slog.Error("Failed to get customer", "customerID", customerID, "error", err)
return ""
}
return cust.Metadata["user_did"]
}
// cacheCustomer stores a customer in the in-memory cache.
func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
m.customerCacheMu.Lock()
m.customerCache[userDID] = &cachedCustomer{
customer: cust,
expiresAt: time.Now().Add(customerCacheTTL),
}
m.customerCacheMu.Unlock()
}
const holdTierCacheTTL = 30 * time.Minute
// RefreshHoldTiers queries all managed holds for their tier definitions and caches the results.
// It runs once immediately (with retries for holds that aren't ready yet) and then
// periodically in the background.
// Safe to call from a goroutine.
func (m *Manager) RefreshHoldTiers() {
if !m.Enabled() || len(m.managedHolds) == 0 {
return
}
// On startup, retry a few times with backoff in case holds aren't ready yet.
// This is common in docker-compose where appview starts before the hold.
const maxRetries = 5
const initialDelay = 3 * time.Second
for attempt := range maxRetries {
m.refreshHoldTiersOnce()
// Check if all managed holds are cached
m.holdTierCacheMu.RLock()
allCached := len(m.holdTierCache) == len(m.managedHolds)
m.holdTierCacheMu.RUnlock()
if allCached {
break
}
if attempt < maxRetries-1 {
delay := initialDelay * time.Duration(1<<attempt) // 3s, 6s, 12s, 24s
slog.Info("Some managed holds not yet reachable, retrying",
"attempt", attempt+1, "maxRetries", maxRetries, "retryIn", delay)
time.Sleep(delay)
}
}
ticker := time.NewTicker(holdTierCacheTTL)
defer ticker.Stop()
for range ticker.C {
m.refreshHoldTiersOnce()
}
}
func (m *Manager) refreshHoldTiersOnce() {
for _, holdDID := range m.managedHolds {
resp, err := holdclient.ListTiers(context.Background(), holdDID)
if err != nil {
slog.Warn("Failed to fetch tiers from hold", "holdDID", holdDID, "error", err)
continue
}
m.holdTierCacheMu.Lock()
m.holdTierCache[holdDID] = &cachedHoldTiers{
tiers: resp.Tiers,
expiresAt: time.Now().Add(holdTierCacheTTL),
}
m.holdTierCacheMu.Unlock()
slog.Debug("Cached tier data from hold", "holdDID", holdDID, "tierCount", len(resp.Tiers))
}
}
// aggregateHoldFeatures generates dynamic feature strings for a tier rank
// by aggregating data from all cached managed holds.
// Returns nil if no hold data is available.
func (m *Manager) aggregateHoldFeatures(rank int) []string {
m.holdTierCacheMu.RLock()
defer m.holdTierCacheMu.RUnlock()
if len(m.holdTierCache) == 0 {
return nil
}
var (
minQuota int64 = -1
maxQuota int64
scanCount int
totalHolds int
)
for _, cached := range m.holdTierCache {
if time.Now().After(cached.expiresAt) {
continue
}
if rank >= len(cached.tiers) {
continue
}
totalHolds++
tier := cached.tiers[rank]
if minQuota < 0 || tier.QuotaBytes < minQuota {
minQuota = tier.QuotaBytes
}
if tier.QuotaBytes > maxQuota {
maxQuota = tier.QuotaBytes
}
if tier.ScanOnPush {
scanCount++
}
}
if totalHolds == 0 {
return nil
}
var features []string
// Storage feature
if minQuota == maxQuota {
features = append(features, formatBytes(minQuota)+" storage")
} else {
features = append(features, formatBytes(minQuota)+"-"+formatBytes(maxQuota)+" storage")
}
// Scan on push feature
if scanCount == totalHolds {
features = append(features, "Scan on push")
} else if scanCount*2 >= totalHolds {
features = append(features, "Scan on push (most regions)")
} else if scanCount > 0 {
features = append(features, "Scan on push (some regions)")
}
return features
}
// webhookFeatures generates feature bullet strings for webhook limits.
func webhookFeatures(maxWebhooks int, allTriggers bool) []string {
var features []string
switch {
case maxWebhooks < 0:
features = append(features, "Unlimited webhooks")
case maxWebhooks == 1:
features = append(features, "1 webhook")
case maxWebhooks > 1:
features = append(features, fmt.Sprintf("%d webhooks", maxWebhooks))
}
if allTriggers {
features = append(features, "All webhook triggers")
}
return features
}
// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
units := []string{"KB", "MB", "GB", "TB", "PB"}
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
}
// fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache.
func (m *Manager) fetchPrice(priceID string) (int64, error) {
m.priceCacheMu.RLock()
if cached, ok := m.priceCache[priceID]; ok && time.Now().Before(cached.expiresAt) {
m.priceCacheMu.RUnlock()
return cached.unitAmount, nil
}
m.priceCacheMu.RUnlock()
p, err := price.Get(priceID, nil)
if err != nil {
slog.Warn("Failed to fetch Stripe price", "priceID", priceID, "error", err)
return 0, err
}
m.priceCacheMu.Lock()
m.priceCache[priceID] = &cachedPrice{
unitAmount: p.UnitAmount,
expiresAt: time.Now().Add(priceCacheTTL),
}
m.priceCacheMu.Unlock()
return p.UnitAmount, nil
}
+72
View File
@@ -0,0 +1,72 @@
//go:build !billing
package billing
import (
"net/http"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/go-chi/chi/v5"
)
// Manager is a no-op billing manager when billing is not compiled in.
type Manager struct {
captainChecker CaptainChecker
}
// New creates a no-op billing manager.
func New(_ *Config, _ *atcrypto.PrivateKeyP256, _ string, _ []string, _ string) *Manager {
return &Manager{}
}
// SetCaptainChecker sets a callback that checks if a user is a hold captain.
func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
m.captainChecker = fn
}
// Enabled returns false when billing is not compiled in.
func (m *Manager) Enabled() bool { return false }
// GetWebhookLimits returns default limits when billing is not compiled in.
// Hold captains get unlimited webhooks with all triggers.
func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
return -1, true
}
return 1, false
}
// GetSubscriptionInfo returns an error when billing is not compiled in.
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
return nil, ErrBillingDisabled
}
// CreateCheckoutSession returns an error when billing is not compiled in.
func (m *Manager) CreateCheckoutSession(_ *http.Request, _, _ string, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
return nil, ErrBillingDisabled
}
// GetBillingPortalURL returns an error when billing is not compiled in.
func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
return nil, ErrBillingDisabled
}
// HandleWebhook returns an error when billing is not compiled in.
func (m *Manager) HandleWebhook(_ *http.Request) error {
return ErrBillingDisabled
}
// GetSupporterBadge returns empty string when billing is not compiled in.
// Hold captains get a "Captain" badge.
func (m *Manager) GetSupporterBadge(userDID string) string {
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
return "Captain"
}
return ""
}
// RegisterRoutes is a no-op when billing is not compiled in.
func (m *Manager) RegisterRoutes(_ chi.Router) {}
// RefreshHoldTiers is a no-op when billing is not compiled in.
func (m *Manager) RefreshHoldTiers() {}
+83
View File
@@ -0,0 +1,83 @@
package billing
// Config holds appview billing/Stripe configuration.
// Parsed from the appview config YAML's billing section.
type Config struct {
// Stripe secret key (sk_test_... or sk_live_...).
// Can also be set via STRIPE_SECRET_KEY env var (takes precedence over config).
// Billing is enabled automatically when this key is set (requires -tags billing build).
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."`
// Stripe webhook signing secret (whsec_...).
// Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence over config).
WebhookSecret string `yaml:"webhook_secret" comment:"Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence)."`
// Currency code for Stripe checkout (e.g. "usd").
Currency string `yaml:"currency" comment:"ISO 4217 currency code (e.g. \"usd\")."`
// URL to redirect after successful checkout. {base_url} is replaced at runtime.
SuccessURL string `yaml:"success_url" comment:"Redirect URL after successful checkout. Use {base_url} placeholder."`
// URL to redirect after cancelled checkout. {base_url} is replaced at runtime.
CancelURL string `yaml:"cancel_url" comment:"Redirect URL after cancelled checkout. Use {base_url} placeholder."`
// Subscription tiers with Stripe price IDs.
Tiers []BillingTierConfig `yaml:"tiers" comment:"Subscription tiers ordered by rank (lowest to highest)."`
// Whether hold owners get a supporter badge on their profile.
OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on hold owner profiles."`
}
// BillingTierConfig represents a single tier with optional Stripe pricing.
type BillingTierConfig struct {
// Tier name (matches hold quota tier names for rank mapping).
Name string `yaml:"name" comment:"Tier name. Position in list determines rank (0-based)."`
// Short description shown on the plan card.
Description string `yaml:"description,omitempty" comment:"Short description shown on the plan card."`
// List of features included in this tier (rendered as bullet points).
Features []string `yaml:"features,omitempty" comment:"List of features included in this tier."`
// Stripe price ID for monthly billing. Empty = free tier.
StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty" comment:"Stripe price ID for monthly billing. Empty = free tier."`
// Stripe price ID for yearly billing.
StripePriceYearly string `yaml:"stripe_price_yearly,omitempty" comment:"Stripe price ID for yearly billing."`
// Maximum number of webhooks for this tier (-1 = unlimited).
MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhooks for this tier (-1 = unlimited)."`
// Whether all webhook trigger types are available (not just first-scan).
WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."`
// Whether this tier earns a supporter badge on user profiles.
SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."`
}
// GetTierByPriceID finds the tier that contains the given Stripe price ID.
// Returns the tier name and rank, or empty string and -1 if not found.
func (c *Config) GetTierByPriceID(priceID string) (string, int) {
if c == nil || priceID == "" {
return "", -1
}
for i, tier := range c.Tiers {
if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID {
return tier.Name, i
}
}
return "", -1
}
// TierRank returns the 0-based rank of a tier by name, or -1 if not found.
func (c *Config) TierRank(name string) int {
if c == nil {
return -1
}
for i, tier := range c.Tiers {
if tier.Name == name {
return i
}
}
return -1
}
+57
View File
@@ -0,0 +1,57 @@
//go:build billing
package billing
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
)
// RegisterRoutes registers billing HTTP routes on the router.
// These routes handle subscription management and Stripe webhooks.
func (m *Manager) RegisterRoutes(r chi.Router) {
if !m.Enabled() {
slog.Info("Billing routes disabled (not configured)")
return
}
slog.Info("Registering billing routes")
// Stripe webhook (public, verified by Stripe signature)
r.Post("/api/stripe/webhook", m.handleStripeWebhook)
}
// handleStripeWebhook processes incoming Stripe webhook events.
func (m *Manager) handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
if err := m.HandleWebhook(r); err != nil {
slog.Error("Stripe webhook error", "error", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(`{"received": true}`)); err != nil {
slog.Error("Failed to write webhook response", "error", err)
}
}
// HandleGetSubscription is an HTTP handler that returns subscription info as JSON.
// Used by the settings page HTMX endpoint.
func (m *Manager) HandleGetSubscription(w http.ResponseWriter, r *http.Request, userDID string) {
info, err := m.GetSubscriptionInfo(userDID)
if err != nil {
w.WriteHeader(http.StatusOK)
if _, writeErr := w.Write([]byte("")); writeErr != nil {
slog.Error("Failed to write empty response", "error", writeErr)
}
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(info); err != nil {
slog.Error("Failed to encode subscription info", "error", err)
}
}
+59
View File
@@ -0,0 +1,59 @@
// Package billing provides optional Stripe billing integration for the appview.
// Build with -tags billing to enable Stripe integration.
// Without the tag, no-op stubs are compiled instead.
package billing
import "errors"
// ErrBillingDisabled is returned when billing operations are attempted
// but billing is not compiled in or not configured.
var ErrBillingDisabled = errors.New("billing not enabled")
// CaptainChecker returns true if a user DID is the captain (owner) of a managed hold.
// Used to bypass billing feature gates for hold operators.
type CaptainChecker func(userDID string) bool
// SubscriptionInfo contains subscription information for a user.
type SubscriptionInfo struct {
UserDID string `json:"userDid"`
CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default)
TierRank int `json:"tierRank"` // 0-based rank index
PaymentsEnabled bool `json:"paymentsEnabled"` // whether billing is active
Tiers []TierInfo `json:"tiers"` // available tiers with pricing
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active
CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly"
}
// TierInfo describes a single tier available for subscription.
type TierInfo struct {
ID string `json:"id"` // tier key
Name string `json:"name"` // display name
Description string `json:"description,omitempty"` // short description for the plan card
Features []string `json:"features,omitempty"` // feature bullet points
Rank int `json:"rank"` // 0-based rank
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free)
PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available)
MaxWebhooks int `json:"maxWebhooks"` // max webhooks (-1 = unlimited)
WebhookAllTriggers bool `json:"webhookAllTriggers,omitempty"` // all trigger types available
SupporterBadge bool `json:"supporterBadge,omitempty"` // earns supporter badge on profile
IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier
}
// CheckoutSessionRequest is the request to create a Stripe checkout session.
type CheckoutSessionRequest struct {
Tier string `json:"tier"`
Interval string `json:"interval,omitempty"` // "monthly" or "yearly"
ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout
}
// CheckoutSessionResponse is the response with the Stripe checkout URL.
type CheckoutSessionResponse struct {
CheckoutURL string `json:"checkoutUrl"`
SessionID string `json:"sessionId"`
}
// BillingPortalResponse is the response with the Stripe billing portal URL.
type BillingPortalResponse struct {
PortalURL string `json:"portalUrl"`
}
+1 -1
View File
@@ -6,7 +6,6 @@
<symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol>
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
<symbol id="badge-check" viewBox="0 0 24 24"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
@@ -19,6 +18,7 @@
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol>
<symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

-565
View File
@@ -1,565 +0,0 @@
//go:build billing
package billing
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
"github.com/stripe/stripe-go/v84/checkout/session"
"github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/price"
"github.com/stripe/stripe-go/v84/subscription"
"github.com/stripe/stripe-go/v84/webhook"
"atcr.io/pkg/hold/quota"
)
// Manager handles Stripe billing integration.
type Manager struct {
quotaMgr *quota.Manager
billingCfg *BillingConfig
holdPublicURL string
stripeKey string
webhookSecret string
publishableKey string
// In-memory cache for customer lookups (DID -> customer)
customerCache map[string]*cachedCustomer
customerCacheMu sync.RWMutex
}
type cachedCustomer struct {
customer *stripe.Customer
expiresAt time.Time
}
const customerCacheTTL = 10 * time.Minute
// New creates a new billing manager with Stripe integration.
// configPath is the path to the hold config YAML file (for billing config parsing).
func New(quotaMgr *quota.Manager, holdPublicURL string, configPath string) *Manager {
stripeKey := os.Getenv("STRIPE_SECRET_KEY")
if stripeKey != "" {
stripe.Key = stripeKey
}
billingCfg, err := LoadBillingConfig(configPath)
if err != nil {
slog.Warn("Failed to load billing config", "error", err)
}
// Validate billing tier names against quota tiers
if billingCfg != nil && billingCfg.Enabled {
for tierName := range billingCfg.Tiers {
if quotaMgr.GetTierLimit(tierName) == nil && tierName != quotaMgr.GetDefaultTier() {
slog.Warn("Billing tier has no matching quota tier", "tier", tierName)
}
}
}
return &Manager{
quotaMgr: quotaMgr,
billingCfg: billingCfg,
holdPublicURL: holdPublicURL,
stripeKey: stripeKey,
webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
publishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
customerCache: make(map[string]*cachedCustomer),
}
}
// Enabled returns true if billing is properly configured.
func (m *Manager) Enabled() bool {
return m.billingCfg != nil && m.billingCfg.Enabled && m.stripeKey != ""
}
// GetSubscriptionInfo returns subscription and quota information for a user.
func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
info := &SubscriptionInfo{
UserDID: userDID,
PaymentsEnabled: true,
Tiers: m.buildTierList(userDID),
}
// Try to find existing customer
cust, err := m.findCustomerByDID(userDID)
if err != nil {
slog.Debug("No Stripe customer found for user", "userDid", userDID)
} else if cust != nil {
info.CustomerID = cust.ID
// Get active subscription if any (check all nil pointers)
if cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 {
sub := cust.Subscriptions.Data[0]
info.SubscriptionID = sub.ID
// Safely access subscription items
if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0].Price != nil {
info.CurrentTier = m.billingCfg.GetTierByPriceID(sub.Items.Data[0].Price.ID)
if sub.Items.Data[0].Price.Recurring != nil {
switch sub.Items.Data[0].Price.Recurring.Interval {
case stripe.PriceRecurringIntervalMonth:
info.BillingInterval = "monthly"
case stripe.PriceRecurringIntervalYear:
info.BillingInterval = "yearly"
}
}
}
}
}
// If no subscription, use default tier
if info.CurrentTier == "" {
info.CurrentTier = m.quotaMgr.GetDefaultTier()
}
// Get quota limit for current tier
limit := m.quotaMgr.GetTierLimit(info.CurrentTier)
info.CurrentLimit = limit
// Mark current tier in tier list
for i := range info.Tiers {
if info.Tiers[i].ID == info.CurrentTier {
info.Tiers[i].IsCurrent = true
}
}
return info, nil
}
// buildTierList creates the list of available tiers by merging quota limits
// from the quota manager with billing metadata from the billing config.
func (m *Manager) buildTierList(userDID string) []TierInfo {
quotaTiers := m.quotaMgr.ListTiers()
if len(quotaTiers) == 0 {
return nil
}
result := make([]TierInfo, 0, len(quotaTiers))
for _, qt := range quotaTiers {
var quotaBytes int64
if qt.Limit != nil {
quotaBytes = *qt.Limit
}
// Capitalize tier ID for display name (e.g., "swabbie" -> "Swabbie")
name := strings.ToUpper(qt.Key[:1]) + qt.Key[1:]
tier := TierInfo{
ID: qt.Key,
Name: name,
QuotaBytes: quotaBytes,
QuotaFormatted: quota.FormatHumanBytes(quotaBytes),
}
// Merge billing metadata if available
if bt := m.billingCfg.GetTierPricing(qt.Key); bt != nil {
tier.Description = bt.Description
// Fetch actual prices from Stripe
if bt.StripePriceMonthly != "" {
if p, err := price.Get(bt.StripePriceMonthly, nil); err == nil && p != nil {
tier.PriceCentsMonthly = int(p.UnitAmount)
} else {
slog.Debug("Failed to fetch monthly price", "priceId", bt.StripePriceMonthly, "error", err)
tier.PriceCentsMonthly = -1
}
}
if bt.StripePriceYearly != "" {
if p, err := price.Get(bt.StripePriceYearly, nil); err == nil && p != nil {
tier.PriceCentsYearly = int(p.UnitAmount)
} else {
slog.Debug("Failed to fetch yearly price", "priceId", bt.StripePriceYearly, "error", err)
tier.PriceCentsYearly = -1
}
}
}
result = append(result, tier)
}
// Sort tiers by quota size (ascending)
sort.Slice(result, func(i, j int) bool {
return result[i].QuotaBytes < result[j].QuotaBytes
})
return result
}
// CreateCheckoutSession creates a Stripe checkout session for subscription.
func (m *Manager) CreateCheckoutSession(r *http.Request, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
// Get user DID from request context (set by auth middleware)
userDID := r.Header.Get("X-User-DID")
if userDID == "" {
return nil, errors.New("user not authenticated")
}
// Get tier config
tierCfg := m.billingCfg.GetTierPricing(req.Tier)
if tierCfg == nil {
return nil, fmt.Errorf("tier not found: %s", req.Tier)
}
// Determine price ID - prefer requested interval, fall back to what's available
var priceID string
switch req.Interval {
case "monthly":
priceID = tierCfg.StripePriceMonthly
case "yearly":
priceID = tierCfg.StripePriceYearly
default:
// No interval specified - prefer monthly, fall back to yearly
if tierCfg.StripePriceMonthly != "" {
priceID = tierCfg.StripePriceMonthly
} else {
priceID = tierCfg.StripePriceYearly
}
}
if priceID == "" {
return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
}
// Get or create customer
cust, err := m.getOrCreateCustomer(userDID)
if err != nil {
return nil, fmt.Errorf("failed to get/create customer: %w", err)
}
// Build success/cancel URLs
successURL := strings.ReplaceAll(m.billingCfg.SuccessURL, "{hold_url}", m.holdPublicURL)
cancelURL := strings.ReplaceAll(m.billingCfg.CancelURL, "{hold_url}", m.holdPublicURL)
if req.ReturnURL != "" {
successURL = req.ReturnURL + "?success=true"
cancelURL = req.ReturnURL + "?cancelled=true"
}
// Create checkout session
params := &stripe.CheckoutSessionParams{
Customer: stripe.String(cust.ID),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(successURL),
CancelURL: stripe.String(cancelURL),
}
sess, err := session.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create checkout session: %w", err)
}
return &CheckoutSessionResponse{
CheckoutURL: sess.URL,
SessionID: sess.ID,
}, nil
}
// GetBillingPortalURL returns a URL to the Stripe billing portal.
func (m *Manager) GetBillingPortalURL(userDID string, returnURL string) (*BillingPortalResponse, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
// Find existing customer
cust, err := m.findCustomerByDID(userDID)
if err != nil || cust == nil {
return nil, errors.New("no billing account found")
}
if returnURL == "" {
returnURL = m.holdPublicURL
}
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(cust.ID),
ReturnURL: stripe.String(returnURL),
}
sess, err := portalsession.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create portal session: %w", err)
}
return &BillingPortalResponse{
PortalURL: sess.URL,
}, nil
}
// HandleWebhook processes a Stripe webhook event.
func (m *Manager) HandleWebhook(r *http.Request) (*WebhookEvent, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
// Verify webhook signature
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
if err != nil {
return nil, fmt.Errorf("failed to verify webhook signature: %w", err)
}
result := &WebhookEvent{
Type: string(event.Type),
}
switch event.Type {
case "checkout.session.completed":
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return nil, fmt.Errorf("failed to parse checkout session: %w", err)
}
result.CustomerID = sess.Customer.ID
result.SubscriptionID = sess.Subscription.ID
result.Status = "active"
// Fetch customer to get DID from metadata
result.UserDID = m.getCustomerDID(sess.Customer.ID)
// Get subscription to find the price/tier
if sess.Subscription != nil && sess.Subscription.ID != "" {
if sub, err := m.getSubscription(sess.Subscription.ID); err == nil && sub != nil {
if len(sub.Items.Data) > 0 {
result.PriceID = sub.Items.Data[0].Price.ID
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
}
}
}
if result.UserDID != "" && result.NewTier != "" {
slog.Info("Checkout completed",
"userDid", result.UserDID,
"tier", result.NewTier,
"subscriptionId", result.SubscriptionID,
)
}
case "customer.subscription.created", "customer.subscription.updated":
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("failed to parse subscription: %w", err)
}
result.SubscriptionID = sub.ID
result.CustomerID = sub.Customer.ID
result.Status = string(sub.Status)
if len(sub.Items.Data) > 0 {
result.PriceID = sub.Items.Data[0].Price.ID
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
}
// Fetch customer to get DID from metadata (webhook doesn't include expanded customer)
result.UserDID = m.getCustomerDID(sub.Customer.ID)
// If we have user DID and new tier, this signals that crew tier should be updated
if result.UserDID != "" && result.NewTier != "" && sub.Status == stripe.SubscriptionStatusActive {
slog.Info("Subscription activated",
"userDid", result.UserDID,
"tier", result.NewTier,
"subscriptionId", result.SubscriptionID,
)
}
case "customer.subscription.deleted", "customer.subscription.paused":
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("failed to parse subscription: %w", err)
}
result.SubscriptionID = sub.ID
result.CustomerID = sub.Customer.ID
if event.Type == "customer.subscription.deleted" {
result.Status = "cancelled"
} else {
result.Status = "paused"
}
// Fetch customer to get DID from metadata
result.UserDID = m.getCustomerDID(sub.Customer.ID)
// Set tier to default (downgrade on cancellation/pause)
result.NewTier = m.quotaMgr.GetDefaultTier()
if result.UserDID != "" {
slog.Info("Subscription inactive, downgrading to default tier",
"userDid", result.UserDID,
"tier", result.NewTier,
"status", result.Status,
)
}
case "customer.subscription.resumed":
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("failed to parse subscription: %w", err)
}
result.SubscriptionID = sub.ID
result.CustomerID = sub.Customer.ID
result.Status = "active"
if len(sub.Items.Data) > 0 {
result.PriceID = sub.Items.Data[0].Price.ID
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
}
// Fetch customer to get DID from metadata
result.UserDID = m.getCustomerDID(sub.Customer.ID)
if result.UserDID != "" && result.NewTier != "" {
slog.Info("Subscription resumed, restoring tier",
"userDid", result.UserDID,
"tier", result.NewTier,
)
}
}
return result, nil
}
// getOrCreateCustomer finds or creates a Stripe customer for the given DID.
func (m *Manager) getOrCreateCustomer(userDID string) (*stripe.Customer, error) {
// Check cache first
m.customerCacheMu.RLock()
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
m.customerCacheMu.RUnlock()
return cached.customer, nil
}
m.customerCacheMu.RUnlock()
// Try to find existing customer
cust, err := m.findCustomerByDID(userDID)
if err == nil && cust != nil {
m.cacheCustomer(userDID, cust)
return cust, nil
}
// Create new customer
params := &stripe.CustomerParams{
Metadata: map[string]string{
"user_did": userDID,
"hold_did": m.holdPublicURL, // Not actually a DID but useful for tracking
},
}
cust, err = customer.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create customer: %w", err)
}
m.cacheCustomer(userDID, cust)
return cust, nil
}
// findCustomerByDID searches Stripe for a customer with the given DID in metadata.
func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
// Check cache first
m.customerCacheMu.RLock()
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
m.customerCacheMu.RUnlock()
return cached.customer, nil
}
m.customerCacheMu.RUnlock()
// Search Stripe by metadata
params := &stripe.CustomerSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
},
}
params.AddExpand("data.subscriptions")
iter := customer.Search(params)
if iter.Next() {
cust := iter.Customer()
m.cacheCustomer(userDID, cust)
return cust, nil
}
if err := iter.Err(); err != nil {
return nil, err
}
return nil, nil // Not found
}
// cacheCustomer adds a customer to the in-memory cache.
func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
m.customerCacheMu.Lock()
defer m.customerCacheMu.Unlock()
m.customerCache[userDID] = &cachedCustomer{
customer: cust,
expiresAt: time.Now().Add(customerCacheTTL),
}
}
// InvalidateCustomerCache removes a customer from the cache.
func (m *Manager) InvalidateCustomerCache(userDID string) {
m.customerCacheMu.Lock()
defer m.customerCacheMu.Unlock()
delete(m.customerCache, userDID)
}
// getCustomerDID fetches a customer by ID and returns the user_did from metadata.
func (m *Manager) getCustomerDID(customerID string) string {
if customerID == "" {
return ""
}
cust, err := customer.Get(customerID, nil)
if err != nil {
slog.Debug("Failed to fetch customer", "customerId", customerID, "error", err)
return ""
}
if cust.Metadata != nil {
return cust.Metadata["user_did"]
}
return ""
}
// getSubscription fetches a subscription by ID.
func (m *Manager) getSubscription(subscriptionID string) (*stripe.Subscription, error) {
if subscriptionID == "" {
return nil, nil
}
params := &stripe.SubscriptionParams{}
params.AddExpand("items.data.price")
return subscription.Get(subscriptionID, params)
}
-60
View File
@@ -1,60 +0,0 @@
//go:build !billing
package billing
import (
"net/http"
"github.com/go-chi/chi/v5"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/hold/quota"
)
// Manager is a no-op billing manager when billing is not compiled in.
type Manager struct{}
// New creates a new no-op billing manager.
// This is used when the billing build tag is not set.
func New(_ *quota.Manager, _ string, _ string) *Manager {
return &Manager{}
}
// Enabled returns false when billing is not compiled in.
func (m *Manager) Enabled() bool {
return false
}
// RegisterHandlers is a no-op when billing is not compiled in.
func (m *Manager) RegisterHandlers(_ chi.Router) {}
// GetSubscriptionInfo returns an error when billing is not compiled in.
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
return nil, ErrBillingDisabled
}
// CreateCheckoutSession returns an error when billing is not compiled in.
func (m *Manager) CreateCheckoutSession(_ *http.Request, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
return nil, ErrBillingDisabled
}
// GetBillingPortalURL returns an error when billing is not compiled in.
func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
return nil, ErrBillingDisabled
}
// HandleWebhook returns an error when billing is not compiled in.
func (m *Manager) HandleWebhook(_ *http.Request) (*WebhookEvent, error) {
return nil, ErrBillingDisabled
}
// XRPCHandler is a no-op handler when billing is not compiled in.
type XRPCHandler struct{}
// NewXRPCHandler creates a new no-op XRPC handler.
func NewXRPCHandler(_ *Manager, _ *pds.HoldPDS, _ *http.Client) *XRPCHandler {
return &XRPCHandler{}
}
// RegisterHandlers is a no-op when billing is not compiled in.
func (h *XRPCHandler) RegisterHandlers(_ chi.Router) {}
-132
View File
@@ -1,132 +0,0 @@
//go:build billing
package billing
import (
"fmt"
"os"
"go.yaml.in/yaml/v4"
)
// BillingConfig holds billing/Stripe settings parsed from the hold config YAML.
// The billing section is a top-level key in the YAML file, separate from quota.
type BillingConfig struct {
Enabled bool
Currency string
SuccessURL string
CancelURL string
// Tier-level billing info keyed by tier name (same keys as quota tiers).
Tiers map[string]BillingTierConfig
// Tier assigned to plankowner crew members.
PlankOwnerCrewTier string
}
// BillingTierConfig holds Stripe pricing for a single tier.
type BillingTierConfig struct {
Description string `yaml:"description,omitempty"`
StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"`
StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"`
}
// billingYAML is the top-level YAML structure for extracting the billing section.
type billingYAML struct {
Billing rawBillingConfig `yaml:"billing"`
}
type rawBillingConfig struct {
Enabled bool `yaml:"enabled"`
Currency string `yaml:"currency,omitempty"`
SuccessURL string `yaml:"success_url,omitempty"`
CancelURL string `yaml:"cancel_url,omitempty"`
PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"`
Tiers map[string]BillingTierConfig `yaml:"tiers,omitempty"`
}
// LoadBillingConfig reads the hold config YAML and extracts billing fields.
// Returns (nil, nil) if the file is missing or billing is not enabled.
// Returns (nil, err) if the file exists with billing enabled but is misconfigured.
func LoadBillingConfig(configPath string) (*BillingConfig, error) {
if configPath == "" {
return nil, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
return parseBillingConfig(data)
}
// parseBillingConfig extracts billing fields from hold config YAML bytes.
// Returns (nil, nil) if billing is not enabled.
// Returns (nil, err) if billing is enabled but misconfigured.
func parseBillingConfig(data []byte) (*BillingConfig, error) {
var raw billingYAML
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if !raw.Billing.Enabled {
return nil, nil
}
cfg := &BillingConfig{
Enabled: true,
Currency: raw.Billing.Currency,
SuccessURL: raw.Billing.SuccessURL,
CancelURL: raw.Billing.CancelURL,
PlankOwnerCrewTier: raw.Billing.PlankOwnerCrewTier,
Tiers: raw.Billing.Tiers,
}
if cfg.Tiers == nil {
cfg.Tiers = make(map[string]BillingTierConfig)
}
// Validate: billing enabled but no tiers have any Stripe prices configured
hasAnyPrice := false
for _, tier := range cfg.Tiers {
if tier.StripePriceMonthly != "" || tier.StripePriceYearly != "" {
hasAnyPrice = true
break
}
}
if !hasAnyPrice {
return nil, fmt.Errorf("billing is enabled but no tiers have Stripe prices configured")
}
return cfg, nil
}
// GetTierPricing returns billing info for a tier, or nil if not found.
func (c *BillingConfig) GetTierPricing(tierKey string) *BillingTierConfig {
if c == nil {
return nil
}
t, ok := c.Tiers[tierKey]
if !ok {
return nil
}
return &t
}
// GetTierByPriceID finds the tier key that contains the given Stripe price ID.
// Returns empty string if no match.
func (c *BillingConfig) GetTierByPriceID(priceID string) string {
if c == nil || priceID == "" {
return ""
}
for key, tier := range c.Tiers {
if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID {
return key
}
}
return ""
}
-357
View File
@@ -1,357 +0,0 @@
//go:build billing
package billing
import (
"os"
"path/filepath"
"testing"
)
func TestParseBillingConfig_Disabled(t *testing.T) {
yaml := []byte(`
billing:
enabled: false
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Error("expected nil config when billing disabled")
}
}
func TestParseBillingConfig_NoBillingSection(t *testing.T) {
yaml := []byte(`
quota:
tiers:
deckhand:
quota: 5GB
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Error("expected nil config when no billing section")
}
}
func TestParseBillingConfig_Enabled(t *testing.T) {
yaml := []byte(`
billing:
enabled: true
currency: usd
success_url: "{hold_url}/billing/success"
cancel_url: "{hold_url}/billing/cancel"
plankowner_crew_tier: bosun
tiers:
deckhand:
description: Starter tier
bosun:
description: Standard tier
stripe_price_monthly: price_bosun_monthly
stripe_price_yearly: price_bosun_yearly
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if !cfg.Enabled {
t.Error("expected Enabled=true")
}
if cfg.Currency != "usd" {
t.Errorf("expected currency 'usd', got %q", cfg.Currency)
}
if cfg.PlankOwnerCrewTier != "bosun" {
t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier)
}
if cfg.SuccessURL != "{hold_url}/billing/success" {
t.Errorf("unexpected success_url: %q", cfg.SuccessURL)
}
// Check tier pricing
bosun := cfg.GetTierPricing("bosun")
if bosun == nil {
t.Fatal("expected bosun tier pricing")
}
if bosun.StripePriceMonthly != "price_bosun_monthly" {
t.Errorf("expected bosun monthly price 'price_bosun_monthly', got %q", bosun.StripePriceMonthly)
}
if bosun.StripePriceYearly != "price_bosun_yearly" {
t.Errorf("expected bosun yearly price 'price_bosun_yearly', got %q", bosun.StripePriceYearly)
}
if bosun.Description != "Standard tier" {
t.Errorf("expected bosun description 'Standard tier', got %q", bosun.Description)
}
// Deckhand has no prices
deckhand := cfg.GetTierPricing("deckhand")
if deckhand == nil {
t.Fatal("expected deckhand tier pricing entry")
}
if deckhand.StripePriceMonthly != "" {
t.Error("expected no monthly price for deckhand")
}
}
func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) {
yaml := []byte(`
billing:
enabled: true
currency: usd
`)
cfg, err := parseBillingConfig(yaml)
if err == nil {
t.Error("expected error when billing enabled but no prices configured")
}
if cfg != nil {
t.Error("expected nil config on error")
}
}
func TestGetTierByPriceID(t *testing.T) {
cfg := &BillingConfig{
Tiers: map[string]BillingTierConfig{
"deckhand": {},
"bosun": {StripePriceMonthly: "price_m", StripePriceYearly: "price_y"},
},
}
if got := cfg.GetTierByPriceID("price_m"); got != "bosun" {
t.Errorf("expected 'bosun' for monthly price, got %q", got)
}
if got := cfg.GetTierByPriceID("price_y"); got != "bosun" {
t.Errorf("expected 'bosun' for yearly price, got %q", got)
}
if got := cfg.GetTierByPriceID("price_unknown"); got != "" {
t.Errorf("expected empty for unknown price, got %q", got)
}
if got := cfg.GetTierByPriceID(""); got != "" {
t.Errorf("expected empty for empty price, got %q", got)
}
// nil receiver
var nilCfg *BillingConfig
if got := nilCfg.GetTierByPriceID("price_m"); got != "" {
t.Errorf("expected empty from nil config, got %q", got)
}
}
func TestGetTierPricing_NilConfig(t *testing.T) {
var cfg *BillingConfig
if cfg.GetTierPricing("anything") != nil {
t.Error("expected nil from nil config")
}
}
func TestLoadBillingConfig_MissingFile(t *testing.T) {
cfg, err := LoadBillingConfig("/nonexistent/config.yaml")
if err != nil {
t.Fatalf("expected no error for missing file, got: %v", err)
}
if cfg != nil {
t.Error("expected nil config for missing file")
}
}
func TestLoadBillingConfig_EmptyPath(t *testing.T) {
cfg, err := LoadBillingConfig("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Error("expected nil config for empty path")
}
}
func TestLoadBillingConfig_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := `
billing:
enabled: true
currency: usd
tiers:
bosun:
stripe_price_monthly: price_test
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadBillingConfig(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.GetTierByPriceID("price_test") != "bosun" {
t.Error("expected bosun tier for price_test")
}
}
func TestParseBillingConfig_TopLevelTiers(t *testing.T) {
yaml := []byte(`
billing:
enabled: true
currency: usd
tiers:
deckhand:
description: "Starter tier"
bosun:
description: "Standard tier"
stripe_price_monthly: price_bosun_m
stripe_price_yearly: price_bosun_y
quartermaster:
description: "Pro tier"
stripe_price_monthly: price_qm_m
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if len(cfg.Tiers) != 3 {
t.Errorf("expected 3 tiers, got %d", len(cfg.Tiers))
}
bosun := cfg.GetTierPricing("bosun")
if bosun == nil {
t.Fatal("expected bosun tier")
}
if bosun.Description != "Standard tier" {
t.Errorf("expected description 'Standard tier', got %q", bosun.Description)
}
if bosun.StripePriceMonthly != "price_bosun_m" {
t.Errorf("expected monthly price 'price_bosun_m', got %q", bosun.StripePriceMonthly)
}
if bosun.StripePriceYearly != "price_bosun_y" {
t.Errorf("expected yearly price 'price_bosun_y', got %q", bosun.StripePriceYearly)
}
qm := cfg.GetTierPricing("quartermaster")
if qm == nil {
t.Fatal("expected quartermaster tier")
}
if qm.StripePriceMonthly != "price_qm_m" {
t.Errorf("expected monthly price 'price_qm_m', got %q", qm.StripePriceMonthly)
}
deckhand := cfg.GetTierPricing("deckhand")
if deckhand == nil {
t.Fatal("expected deckhand tier")
}
if deckhand.Description != "Starter tier" {
t.Errorf("expected description 'Starter tier', got %q", deckhand.Description)
}
if deckhand.StripePriceMonthly != "" {
t.Error("expected no monthly price for deckhand")
}
}
func TestParseBillingConfig_PlankOwnerCrewTier(t *testing.T) {
yaml := []byte(`
billing:
enabled: true
currency: usd
plankowner_crew_tier: bosun
tiers:
bosun:
stripe_price_monthly: price_bosun_m
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.PlankOwnerCrewTier != "bosun" {
t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier)
}
}
func TestParseBillingConfig_IgnoresQuotaSection(t *testing.T) {
// Billing parser should work even if quota section is missing entirely
yaml := []byte(`
billing:
enabled: true
currency: usd
tiers:
bosun:
stripe_price_monthly: price_bosun_m
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
// Also works with quota present but unrelated
yaml2 := []byte(`
quota:
tiers:
swabbie:
quota: 1GB
billing:
enabled: true
currency: usd
tiers:
bosun:
stripe_price_monthly: price_bosun_m
`)
cfg2, err := parseBillingConfig(yaml2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg2 == nil {
t.Fatal("expected non-nil config")
}
// Billing should only see its own tiers, not quota tiers
if cfg2.GetTierPricing("swabbie") != nil {
t.Error("billing should not contain quota-only tiers")
}
}
func TestParseBillingConfig_EmptyTiers(t *testing.T) {
// Billing enabled with explicit empty tiers
yaml := []byte(`
billing:
enabled: true
currency: usd
tiers: {}
`)
cfg, err := parseBillingConfig(yaml)
if err == nil {
t.Error("expected error when billing enabled with empty tiers")
}
if cfg != nil {
t.Error("expected nil config on error")
}
// Billing enabled with tiers omitted entirely
yaml2 := []byte(`
billing:
enabled: true
currency: usd
`)
cfg2, err := parseBillingConfig(yaml2)
if err == nil {
t.Error("expected error when billing enabled with no tiers")
}
if cfg2 != nil {
t.Error("expected nil config on error")
}
}
-222
View File
@@ -1,222 +0,0 @@
//go:build billing
package billing
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"atcr.io/pkg/hold/pds"
)
// XRPCHandler handles billing-related XRPC endpoints.
type XRPCHandler struct {
manager *Manager
pdsServer *pds.HoldPDS
httpClient *http.Client
}
// NewXRPCHandler creates a new billing XRPC handler.
func NewXRPCHandler(manager *Manager, pdsServer *pds.HoldPDS, httpClient *http.Client) *XRPCHandler {
return &XRPCHandler{
manager: manager,
pdsServer: pdsServer,
httpClient: httpClient,
}
}
// RegisterHandlers registers billing XRPC endpoints on the router.
func (m *Manager) RegisterHandlers(r chi.Router) {
// This is a no-op for the Manager itself
// Use NewXRPCHandler and call its RegisterHandlers method
}
// RegisterHandlers registers billing endpoints on the router.
func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
if !h.manager.Enabled() {
slog.Info("Billing endpoints disabled (not configured)")
return
}
slog.Info("Registering billing XRPC endpoints")
// Public endpoint - get subscription info (auth optional for tiers list)
r.Get("/xrpc/io.atcr.hold.getSubscriptionInfo", h.HandleGetSubscriptionInfo)
// Authenticated endpoints
r.Group(func(r chi.Router) {
r.Use(h.requireAuth)
r.Post("/xrpc/io.atcr.hold.createCheckoutSession", h.HandleCreateCheckoutSession)
r.Get("/xrpc/io.atcr.hold.getBillingPortalUrl", h.HandleGetBillingPortalURL)
})
// Stripe webhook (authenticated by Stripe signature)
r.Post("/xrpc/io.atcr.hold.stripeWebhook", h.HandleStripeWebhook)
}
// requireAuth is middleware that validates user authentication.
func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Use the same auth validation as other hold endpoints
user, err := pds.ValidateDPoPRequest(r, h.httpClient)
if err != nil {
// Try service token
user, err = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
}
if err != nil {
respondError(w, http.StatusUnauthorized, "authentication required")
return
}
// Store user DID in header for handlers
r.Header.Set("X-User-DID", user.DID)
next.ServeHTTP(w, r)
})
}
// HandleGetSubscriptionInfo returns subscription and quota information.
// GET /xrpc/io.atcr.hold.getSubscriptionInfo?userDid=did:plc:xxx
func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.Request) {
userDID := r.URL.Query().Get("userDid")
// If no userDID provided, try to get from auth
if userDID == "" {
// Try to authenticate (optional)
user, err := pds.ValidateDPoPRequest(r, h.httpClient)
if err != nil {
user, _ = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
}
if user != nil {
userDID = user.DID
}
}
info, err := h.manager.GetSubscriptionInfo(userDID)
if err != nil {
if err == ErrBillingDisabled {
// Return basic info with payments disabled
respondJSON(w, http.StatusOK, &SubscriptionInfo{
UserDID: userDID,
PaymentsEnabled: false,
Tiers: h.manager.buildTierList(userDID),
})
return
}
respondError(w, http.StatusInternalServerError, err.Error())
return
}
// Get current usage and crew tier from PDS quota stats
if userDID != "" {
stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr)
if err == nil {
info.CurrentUsage = stats.TotalSize
info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced)
info.CurrentLimit = stats.Limit
// If no subscription but crew has a tier, show that as current
if info.SubscriptionID == "" && info.CrewTier != "" {
info.CurrentTier = info.CrewTier
}
}
}
// Mark which tier is actually current (use crew tier if available, otherwise subscription tier)
effectiveTier := info.CurrentTier
if info.CrewTier != "" {
effectiveTier = info.CrewTier
}
for i := range info.Tiers {
info.Tiers[i].IsCurrent = info.Tiers[i].ID == effectiveTier
}
respondJSON(w, http.StatusOK, info)
}
// HandleCreateCheckoutSession creates a Stripe checkout session.
// POST /xrpc/io.atcr.hold.createCheckoutSession
func (h *XRPCHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
var req CheckoutSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Tier == "" {
respondError(w, http.StatusBadRequest, "tier is required")
return
}
resp, err := h.manager.CreateCheckoutSession(r, &req)
if err != nil {
slog.Error("Failed to create checkout session", "error", err)
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, resp)
}
// HandleGetBillingPortalURL returns a URL to the Stripe billing portal.
// GET /xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=https://...
func (h *XRPCHandler) HandleGetBillingPortalURL(w http.ResponseWriter, r *http.Request) {
userDID := r.Header.Get("X-User-DID")
returnURL := r.URL.Query().Get("returnUrl")
resp, err := h.manager.GetBillingPortalURL(userDID, returnURL)
if err != nil {
slog.Error("Failed to get billing portal URL", "error", err, "userDid", userDID)
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, resp)
}
// HandleStripeWebhook processes Stripe webhook events.
// POST /xrpc/io.atcr.hold.stripeWebhook
func (h *XRPCHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
event, err := h.manager.HandleWebhook(r)
if err != nil {
slog.Error("Failed to process webhook", "error", err)
respondError(w, http.StatusBadRequest, err.Error())
return
}
// If we have a tier update, apply it to the crew record
if event.UserDID != "" && event.NewTier != "" {
if err := h.pdsServer.UpdateCrewMemberTier(r.Context(), event.UserDID, event.NewTier); err != nil {
slog.Error("Failed to update crew tier", "error", err, "userDid", event.UserDID, "tier", event.NewTier)
// Don't fail the webhook - Stripe will retry
} else {
slog.Info("Updated crew tier from subscription",
"userDid", event.UserDID,
"tier", event.NewTier,
"event", event.Type,
)
}
// Invalidate customer cache since subscription changed
h.manager.InvalidateCustomerCache(event.UserDID)
}
// Return 200 to acknowledge receipt
respondJSON(w, http.StatusOK, map[string]string{"received": "true"})
}
// respondJSON writes a JSON response.
func respondJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("Failed to encode JSON response", "error", err)
}
}
// respondError writes a JSON error response.
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
-65
View File
@@ -1,65 +0,0 @@
// Package billing provides optional Stripe billing integration for hold services.
// This package uses build tags to conditionally compile Stripe support.
// Build with -tags billing to enable Stripe integration.
package billing
import "errors"
// ErrBillingDisabled is returned when billing operations are attempted
// but billing is not enabled (either not compiled in or disabled at runtime).
var ErrBillingDisabled = errors.New("billing not enabled")
// SubscriptionInfo contains subscription and quota information for a user.
type SubscriptionInfo struct {
UserDID string `json:"userDid"`
CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default)
CrewTier string `json:"crewTier,omitempty"` // tier from local crew record (what's actually enforced)
CurrentUsage int64 `json:"currentUsage"` // bytes used
CurrentLimit *int64 `json:"currentLimit,omitempty"` // nil = unlimited
PaymentsEnabled bool `json:"paymentsEnabled"` // whether online payments are available
Tiers []TierInfo `json:"tiers"` // available tiers
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active
CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly"
}
// TierInfo describes a single tier available for subscription.
type TierInfo struct {
ID string `json:"id"` // tier key (e.g., "deckhand", "bosun")
Name string `json:"name"` // display name (same as ID if not specified)
Description string `json:"description,omitempty"` // human-readable description
QuotaBytes int64 `json:"quotaBytes"` // quota limit in bytes
QuotaFormatted string `json:"quotaFormatted"` // human-readable quota (e.g., "5 GB")
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free)
PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available)
IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier
}
// CheckoutSessionRequest is the request to create a Stripe checkout session.
type CheckoutSessionRequest struct {
Tier string `json:"tier"` // tier to subscribe to
Interval string `json:"interval,omitempty"` // "monthly" or "yearly" (default: monthly)
ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout
}
// CheckoutSessionResponse is the response with the Stripe checkout URL.
type CheckoutSessionResponse struct {
CheckoutURL string `json:"checkoutUrl"`
SessionID string `json:"sessionId"`
}
// BillingPortalResponse is the response with the Stripe billing portal URL.
type BillingPortalResponse struct {
PortalURL string `json:"portalUrl"`
}
// WebhookEvent represents a processed Stripe webhook event.
type WebhookEvent struct {
Type string `json:"type"` // e.g., "customer.subscription.updated"
CustomerID string `json:"customerId"` // Stripe customer ID
UserDID string `json:"userDid"` // user's DID from customer metadata
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID
PriceID string `json:"priceId,omitempty"` // Stripe price ID
NewTier string `json:"newTier,omitempty"` // resolved tier name
Status string `json:"status,omitempty"` // subscription status
}
+29 -7
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
@@ -19,6 +20,23 @@ import (
"atcr.io/pkg/hold/quota"
)
// URLFromDIDWeb converts a did:web identifier to an HTTPS URL.
// This is the inverse of the did:web spec encoding:
//
// "did:web:atcr.io" → "https://atcr.io"
// "did:web:localhost%3A8080" → "https://localhost:8080"
//
// Returns empty string for non-did:web identifiers.
func URLFromDIDWeb(did string) string {
if !strings.HasPrefix(did, "did:web:") {
return ""
}
host := strings.TrimPrefix(did, "did:web:")
// Per did:web spec, %3A encodes port colon
host = strings.ReplaceAll(host, "%3A", ":")
return "https://" + host
}
// Config represents the hold service configuration
type Config struct {
Version string `yaml:"version" comment:"Configuration format version."`
@@ -128,8 +146,8 @@ type ServerConfig struct {
// Request crawl from this relay on startup.
RelayEndpoint string `yaml:"relay_endpoint" comment:"Request crawl from this relay on startup to make the embedded PDS discoverable."`
// Preferred appview URL for links in webhooks and Bluesky posts.
AppviewURL string `yaml:"appview_url" comment:"Preferred appview URL for links in webhooks and Bluesky posts, e.g. \"https://seamark.dev\"."`
// DID of the appview this hold is managed by. Resolved via did:web for URL and public key discovery.
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."`
// ReadTimeout for HTTP requests.
ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."`
@@ -138,6 +156,11 @@ type ServerConfig struct {
WriteTimeout time.Duration `yaml:"write_timeout" comment:"Write timeout for HTTP requests."`
}
// AppviewURL derives the appview base URL from AppviewDID.
func (s ServerConfig) AppviewURL() string {
return URLFromDIDWeb(s.AppviewDID)
}
// ScannerConfig defines vulnerability scanner settings
type ScannerConfig struct {
// Shared secret for scanner WebSocket authentication. Empty disables scanning.
@@ -189,7 +212,7 @@ func setHoldDefaults(v *viper.Viper) {
v.SetDefault("server.successor", "")
v.SetDefault("server.test_mode", false)
v.SetDefault("server.relay_endpoint", "")
v.SetDefault("server.appview_url", "https://atcr.io")
v.SetDefault("server.appview_did", "did:web:atcr.io")
v.SetDefault("server.read_timeout", "5m")
v.SetDefault("server.write_timeout", "5m")
@@ -252,13 +275,12 @@ func ExampleYAML() ([]byte, error) {
// Populate example quota tiers so operators see the structure
cfg.Quota = quota.Config{
Tiers: []quota.TierConfig{
{Name: "deckhand", Quota: "5GB", MaxWebhooks: 1},
{Name: "bosun", Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true},
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true},
{Name: "deckhand", Quota: "5GB"},
{Name: "bosun", Quota: "50GB", ScanOnPush: true},
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true},
},
Defaults: quota.DefaultsConfig{
NewCrewTier: "deckhand",
OwnerBadge: true,
},
}
+152
View File
@@ -529,6 +529,158 @@ func ValidateServiceToken(r *http.Request, holdDID string, httpClient HTTPClient
}, nil
}
// ValidateAppviewToken validates a JWT signed by the trusted appview using ES256 (P-256).
// It resolves the appview's DID document to extract the P-256 public key, then verifies
// the JWT signature, issuer (iss), and audience (aud).
//
// Returns the subject (sub) claim which is the user DID being acted upon.
func ValidateAppviewToken(r *http.Request, appviewDID, holdDID string) (string, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return "", ErrMissingAuthHeader
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return "", fmt.Errorf("expected Bearer authorization scheme")
}
tokenString := parts[1]
if tokenString == "" {
return "", ErrMissingToken
}
// Manually parse JWT
tokenParts := strings.Split(tokenString, ".")
if len(tokenParts) != 3 {
return "", ErrInvalidJWTFormat
}
// Decode and parse claims
payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
if err != nil {
return "", fmt.Errorf("failed to decode JWT payload: %w", err)
}
var claims ServiceTokenClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return "", fmt.Errorf("failed to unmarshal claims: %w", err)
}
// Verify issuer matches configured appview DID
if claims.Issuer != appviewDID {
return "", fmt.Errorf("token issuer mismatch: expected %s, got %s", appviewDID, claims.Issuer)
}
// Verify audience matches this hold's DID
audiences, err := claims.GetAudience()
if err != nil {
return "", fmt.Errorf("failed to get audience: %w", err)
}
if len(audiences) == 0 || audiences[0] != holdDID {
return "", fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences)
}
// Verify expiration
exp, err := claims.GetExpirationTime()
if err != nil {
return "", fmt.Errorf("failed to get expiration: %w", err)
}
if exp != nil && time.Now().After(exp.Time) {
return "", ErrTokenExpired
}
// Get subject (user DID)
subject, err := claims.GetSubject()
if err != nil || subject == "" {
return "", ErrMissingSubClaim
}
// Fetch P-256 public key from appview DID document
pubKey, err := fetchP256PublicKeyFromDID(r.Context(), appviewDID)
if err != nil {
return "", fmt.Errorf("failed to fetch appview public key: %w", err)
}
// Verify JWT signature with P-256 key
signedData := []byte(tokenParts[0] + "." + tokenParts[1])
signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
if err != nil {
return "", fmt.Errorf("failed to decode signature: %w", err)
}
if err := pubKey.HashAndVerifyLenient(signedData, signature); err != nil {
return "", fmt.Errorf("signature verification failed: %w", err)
}
slog.Debug("Validated appview service token", "appviewDID", appviewDID, "userDID", subject)
return subject, nil
}
// fetchP256PublicKeyFromDID fetches a P-256 public key from a did:web DID document.
// It resolves the DID document and looks for a Multikey verification method with P-256 prefix.
func fetchP256PublicKeyFromDID(ctx context.Context, did string) (*atcrypto.PublicKeyP256, error) {
if !strings.HasPrefix(did, "did:web:") {
return nil, fmt.Errorf("only did:web is supported for appview DID, got %s", did)
}
// Resolve did:web to URL
host := strings.TrimPrefix(did, "did:web:")
host = strings.ReplaceAll(host, "%3A", ":")
scheme := "https"
if atproto.IsTestMode() {
scheme = "http"
}
didDocURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, host)
req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DID document fetch returned status %d", resp.StatusCode)
}
var doc struct {
VerificationMethod []struct {
ID string `json:"id"`
Type string `json:"type"`
PublicKeyMultibase string `json:"publicKeyMultibase"`
} `json:"verificationMethod"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, fmt.Errorf("failed to decode DID document: %w", err)
}
// Find a Multikey verification method with P-256 public key
for _, vm := range doc.VerificationMethod {
if vm.Type != "Multikey" || vm.PublicKeyMultibase == "" {
continue
}
// Try parsing as P-256 key via atcrypto's multibase parser
pubKey, err := atcrypto.ParsePublicMultibase(vm.PublicKeyMultibase)
if err != nil {
continue
}
p256Key, ok := pubKey.(*atcrypto.PublicKeyP256)
if ok {
return p256Key, nil
}
}
return nil, fmt.Errorf("no P-256 public key found in DID document for %s", did)
}
// fetchPublicKeyFromDID fetches the public key from a DID document
// Supports did:plc and did:web
// Returns the atcrypto.PublicKey for signature verification
-18
View File
@@ -145,11 +145,6 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
db.Close()
return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
}
if err := sb.initWebhookSchema(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
}
// Start re-dispatch loop for timed-out jobs
sb.wg.Add(1)
go sb.reDispatchLoop()
@@ -191,10 +186,6 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
if err := sb.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
}
if err := sb.initWebhookSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
}
sb.wg.Add(1)
go sb.reDispatchLoop()
@@ -517,13 +508,6 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
// Store scan result as a record in the hold's embedded PDS
if msg.Summary != nil {
// Check for existing scan record before creating new one (for webhook dispatch)
var previousScan *atproto.ScanRecord
_, prevScan, err := sb.pds.GetScanRecord(ctx, manifestDigest)
if err == nil {
previousScan = prevScan
}
scanRecord := atproto.NewScanRecord(
manifestDigest, repository, userDID,
sbomBlob, vulnReportBlob,
@@ -545,8 +529,6 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
"total", msg.Summary.Total)
}
// Dispatch webhooks after scan record is stored
go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle, msg.Summary, previousScan)
}
// Mark job as completed
+1 -2
View File
@@ -32,7 +32,6 @@ func init() {
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
lexutil.RegisterType(atproto.WebhookCollection, &atproto.HoldWebhookRecord{})
}
// HoldPDS is a minimal ATProto PDS implementation for a hold service
@@ -50,7 +49,7 @@ type HoldPDS struct {
recordsIndex *RecordsIndex
}
// AppviewURL returns the configured appview base URL for links in webhooks and posts.
// AppviewURL returns the configured appview base URL for links in Bluesky posts.
func (p *HoldPDS) AppviewURL() string { return p.appviewURL }
// AppviewMeta returns cached appview metadata, or defaults derived from the appview URL.
-831
View File
@@ -1,831 +0,0 @@
package pds
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/ipfs/go-cid"
)
// webhookConfig represents a webhook for list/display (masked URL, no secret)
type webhookConfig struct {
Rkey string `json:"rkey"`
Triggers int `json:"triggers"`
URL string `json:"url"` // masked
HasSecret bool `json:"hasSecret"`
CreatedAt string `json:"createdAt"`
}
// activeWebhook is the internal representation with secret for dispatch
type activeWebhook struct {
Rkey string
URL string
Secret string
Triggers int
}
// WebhookPayload is the JSON body sent to webhook URLs
type WebhookPayload struct {
Trigger string `json:"trigger"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Manifest WebhookManifestInfo `json:"manifest"`
Scan WebhookScanInfo `json:"scan"`
Previous *WebhookVulnCounts `json:"previous"`
}
// WebhookManifestInfo describes the scanned manifest
type WebhookManifestInfo struct {
Digest string `json:"digest"`
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle,omitempty"`
}
// WebhookScanInfo describes the scan results
type WebhookScanInfo struct {
ScannedAt string `json:"scannedAt"`
ScannerVersion string `json:"scannerVersion"`
Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"`
}
// WebhookVulnCounts contains vulnerability counts by severity
type WebhookVulnCounts struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Total int `json:"total"`
}
// initWebhookSchema creates the webhook_secrets table.
// Called from ScanBroadcaster init alongside scan_jobs table.
func (sb *ScanBroadcaster) initWebhookSchema() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS webhook_secrets (
rkey TEXT PRIMARY KEY,
user_did TEXT NOT NULL,
url TEXT NOT NULL,
secret TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_webhook_secrets_user ON webhook_secrets(user_did)`,
}
for _, stmt := range stmts {
if _, err := sb.db.Exec(stmt); err != nil {
return fmt.Errorf("failed to create webhook_secrets table: %w", err)
}
}
return nil
}
// CountWebhooks returns the number of webhooks configured for a user
func (sb *ScanBroadcaster) CountWebhooks(userDID string) (int, error) {
var count int
err := sb.db.QueryRow(`SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?`, userDID).Scan(&count)
return count, err
}
// ListWebhookConfigs returns webhook configurations for display (masked URLs)
func (sb *ScanBroadcaster) ListWebhookConfigs(userDID string) ([]webhookConfig, error) {
rows, err := sb.db.Query(`
SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
`, userDID)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []webhookConfig
for rows.Next() {
var rkey, rawURL, secret string
if err := rows.Scan(&rkey, &rawURL, &secret); err != nil {
continue
}
// Get triggers from PDS record
triggers := 0
_, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, rkey, cid.Undef)
if err == nil {
if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
triggers = int(rec.Triggers)
}
}
// Get createdAt from PDS record
createdAt := ""
if val != nil {
if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
createdAt = rec.CreatedAt
}
}
configs = append(configs, webhookConfig{
Rkey: rkey,
Triggers: triggers,
URL: maskURL(rawURL),
HasSecret: secret != "",
CreatedAt: createdAt,
})
}
if configs == nil {
configs = []webhookConfig{}
}
return configs, nil
}
// AddWebhookConfig creates a new webhook: stores secret in SQLite, record in PDS
func (sb *ScanBroadcaster) AddWebhookConfig(userDID, webhookURL, secret string, triggers int) (string, cid.Cid, error) {
ctx := context.Background()
// Use TID for rkey — avoids collisions after delete+re-add
rkey := sb.pds.repomgr.NextTID()
// Create PDS record
record := atproto.NewHoldWebhookRecord(userDID, triggers)
_, recordCID, err := sb.pds.repomgr.PutRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey, record)
if err != nil {
return "", cid.Undef, fmt.Errorf("failed to create webhook PDS record: %w", err)
}
// Store secret in SQLite
_, err = sb.db.Exec(`
INSERT INTO webhook_secrets (rkey, user_did, url, secret) VALUES (?, ?, ?, ?)
`, rkey, userDID, webhookURL, secret)
if err != nil {
// Try to clean up PDS record on SQLite failure
_ = sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey)
return "", cid.Undef, fmt.Errorf("failed to store webhook secret: %w", err)
}
return rkey, recordCID, nil
}
// DeleteWebhookConfig deletes a webhook by rkey (validates ownership)
func (sb *ScanBroadcaster) DeleteWebhookConfig(userDID, rkey string) error {
ctx := context.Background()
// Validate ownership
var owner string
err := sb.db.QueryRow(`SELECT user_did FROM webhook_secrets WHERE rkey = ?`, rkey).Scan(&owner)
if err != nil {
return fmt.Errorf("webhook not found")
}
if owner != userDID {
return fmt.Errorf("unauthorized: webhook belongs to a different user")
}
// Delete SQLite row
if _, err := sb.db.Exec(`DELETE FROM webhook_secrets WHERE rkey = ?`, rkey); err != nil {
return fmt.Errorf("failed to delete webhook secret: %w", err)
}
// Delete PDS record
if err := sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey); err != nil {
slog.Warn("Failed to delete webhook PDS record (secret already removed)", "rkey", rkey, "error", err)
}
return nil
}
// GetWebhooksForUser returns all active webhooks with secrets for dispatch
func (sb *ScanBroadcaster) GetWebhooksForUser(userDID string) ([]activeWebhook, error) {
rows, err := sb.db.Query(`
SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
`, userDID)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []activeWebhook
for rows.Next() {
var w activeWebhook
if err := rows.Scan(&w.Rkey, &w.URL, &w.Secret); err != nil {
continue
}
// Get triggers from PDS record
_, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, w.Rkey, cid.Undef)
if err == nil {
if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
w.Triggers = int(rec.Triggers)
}
}
webhooks = append(webhooks, w)
}
return webhooks, nil
}
// dispatchWebhooks fires matching webhooks after a scan completes
func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle string, summary *VulnerabilitySummary, previousScan *atproto.ScanRecord) {
webhooks, err := sb.GetWebhooksForUser(userDID)
if err != nil || len(webhooks) == 0 {
return
}
isFirst := previousScan == nil
isChanged := previousScan != nil && vulnCountsChanged(summary, previousScan)
scanInfo := WebhookScanInfo{
ScannedAt: time.Now().Format(time.RFC3339),
ScannerVersion: "atcr-scanner-v1.0.0",
Vulnerabilities: WebhookVulnCounts{
Critical: summary.Critical,
High: summary.High,
Medium: summary.Medium,
Low: summary.Low,
Total: summary.Total,
},
}
manifestInfo := WebhookManifestInfo{
Digest: manifestDigest,
Repository: repository,
Tag: tag,
UserDID: userDID,
UserHandle: userHandle,
}
for _, wh := range webhooks {
// Check each trigger condition
triggers := []string{}
if wh.Triggers&atproto.TriggerFirst != 0 && isFirst {
triggers = append(triggers, "scan:first")
}
if wh.Triggers&atproto.TriggerAll != 0 {
triggers = append(triggers, "scan:all")
}
if wh.Triggers&atproto.TriggerChanged != 0 && isChanged {
triggers = append(triggers, "scan:changed")
}
for _, trigger := range triggers {
payload := WebhookPayload{
Trigger: trigger,
HoldDID: sb.holdDID,
HoldEndpoint: sb.holdEndpoint,
Manifest: manifestInfo,
Scan: scanInfo,
}
// Include previous counts for scan:changed
if trigger == "scan:changed" && previousScan != nil {
payload.Previous = &WebhookVulnCounts{
Critical: int(previousScan.Critical),
High: int(previousScan.High),
Medium: int(previousScan.Medium),
Low: int(previousScan.Low),
Total: int(previousScan.Total),
}
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
slog.Error("Failed to marshal webhook payload", "error", err)
continue
}
go sb.deliverWithRetry(wh.URL, wh.Secret, payloadBytes)
}
}
}
// deliverWithRetry attempts to deliver a webhook with exponential backoff
func (sb *ScanBroadcaster) deliverWithRetry(webhookURL, secret string, payload []byte) {
delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute}
for attempt, delay := range delays {
if attempt > 0 {
time.Sleep(delay)
}
if sb.attemptDelivery(webhookURL, secret, payload) {
return
}
}
slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL))
}
// attemptDelivery sends a single webhook HTTP POST
func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []byte) bool {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Reformat payload for platform-specific webhook APIs
meta := sb.pds.AppviewMeta()
sendPayload := payload
if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) {
var p WebhookPayload
if err := json.Unmarshal(payload, &p); err == nil {
var formatted []byte
var fmtErr error
if isDiscordWebhook(webhookURL) {
formatted, fmtErr = formatDiscordPayload(p, meta)
} else {
formatted, fmtErr = formatSlackPayload(p, meta)
}
if fmtErr == nil {
sendPayload = formatted
}
}
}
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(sendPayload)))
if err != nil {
slog.Warn("Failed to create webhook request", "error", err)
return false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", meta.ClientShortName+"-Webhook/1.0")
// HMAC signing if secret is set (signs the actual payload sent)
if secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(sendPayload)
sig := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Webhook-Signature-256", "sha256="+sig)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode)
return true
}
// Read response body for debugging (e.g., Discord returns error details)
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
slog.Warn("Webhook delivery got non-2xx response",
"url", maskURL(webhookURL),
"status", resp.StatusCode,
"body", string(body))
return false
}
// vulnCountsChanged checks if vulnerability counts differ between current scan and previous
func vulnCountsChanged(current *VulnerabilitySummary, previous *atproto.ScanRecord) bool {
return current.Critical != int(previous.Critical) ||
current.High != int(previous.High) ||
current.Medium != int(previous.Medium) ||
current.Low != int(previous.Low)
}
// maskURL masks a URL for display (shows scheme + host, hides path/query)
func maskURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
if len(rawURL) > 30 {
return rawURL[:30] + "***"
}
return rawURL
}
masked := u.Scheme + "://" + u.Host
if u.Path != "" && u.Path != "/" {
masked += "/***"
}
return masked
}
// isDiscordWebhook checks if the URL points to a Discord webhook endpoint
func isDiscordWebhook(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com")
}
// isSlackWebhook checks if the URL points to a Slack webhook endpoint
func isSlackWebhook(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return u.Host == "hooks.slack.com"
}
// webhookSeverityColor returns a color int based on the highest severity present
func webhookSeverityColor(vulns WebhookVulnCounts) int {
switch {
case vulns.Critical > 0:
return 0xED4245 // red
case vulns.High > 0:
return 0xFFA500 // orange
case vulns.Medium > 0:
return 0xFEE75C // yellow
case vulns.Low > 0:
return 0x57F287 // green
default:
return 0x95A5A6 // grey
}
}
// webhookSeverityHex returns a hex color string (e.g., "#ED4245")
func webhookSeverityHex(vulns WebhookVulnCounts) string {
return fmt.Sprintf("#%06X", webhookSeverityColor(vulns))
}
// formatVulnDescription builds a vulnerability summary with colored square emojis
func formatVulnDescription(v WebhookVulnCounts, digest string) string {
var lines []string
if len(digest) > 19 {
lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"..."))
}
if v.Total == 0 {
lines = append(lines, "🟩 No vulnerabilities found")
} else {
if v.Critical > 0 {
lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical))
}
if v.High > 0 {
lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High))
}
if v.Medium > 0 {
lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium))
}
if v.Low > 0 {
lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low))
}
}
return strings.Join(lines, "\n")
}
// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format
func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
appviewURL := meta.BaseURL
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest)
// Add previous counts for scan:changed
if p.Trigger == "scan:changed" && p.Previous != nil {
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
}
embed := map[string]any{
"title": title,
"url": appviewURL,
"description": description,
"color": webhookSeverityColor(p.Scan.Vulnerabilities),
"footer": map[string]string{
"text": meta.ClientShortName,
"icon_url": meta.FaviconURL,
},
"timestamp": p.Scan.ScannedAt,
}
// Add author, repo link, and OG image when handle is available
if p.Manifest.UserHandle != "" {
embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
embed["author"] = map[string]string{
"name": p.Manifest.UserHandle,
"url": appviewURL + "/u/" + p.Manifest.UserHandle,
}
embed["image"] = map[string]string{
"url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository),
}
} else {
embed["image"] = map[string]string{
"url": appviewURL + "/og/home",
}
}
payload := map[string]any{
"username": meta.ClientShortName,
"avatar_url": meta.FaviconURL,
"embeds": []any{embed},
}
return json.Marshal(payload)
}
// formatSlackPayload wraps an ATCR webhook payload in Slack's message format
func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
appviewURL := meta.BaseURL
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
v := p.Scan.Vulnerabilities
fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low",
title, v.Critical, v.High, v.Medium, v.Low)
description := formatVulnDescription(v, p.Manifest.Digest)
// Add previous counts for scan:changed
if p.Trigger == "scan:changed" && p.Previous != nil {
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
}
attachment := map[string]any{
"fallback": fallback,
"color": webhookSeverityHex(v),
"title": title,
"text": description,
"footer": meta.ClientShortName,
"footer_icon": meta.FaviconURL,
"ts": p.Scan.ScannedAt,
}
// Add repo link when handle is available
if p.Manifest.UserHandle != "" {
attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
attachment["author_name"] = p.Manifest.UserHandle
attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle
}
payload := map[string]any{
"text": fallback,
"attachments": []any{attachment},
}
return json.Marshal(payload)
}
// isCaptain checks if the given DID is the hold captain (owner)
func (h *XRPCHandler) isCaptain(ctx context.Context, did string) bool {
_, captain, err := h.pds.GetCaptainRecord(ctx)
if err != nil {
slog.Debug("isCaptain: failed to get captain record", "error", err)
return false
}
if captain == nil {
slog.Debug("isCaptain: captain record is nil")
return false
}
match := captain.Owner == did
if !match {
slog.Debug("isCaptain: DID mismatch", "captain.Owner", captain.Owner, "user.DID", did)
}
return match
}
// ---- XRPC Handlers ----
// HandleListWebhooks returns webhook configs for a user
func (h *XRPCHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if h.scanBroadcaster == nil {
render.JSON(w, r, map[string]any{
"webhooks": []any{},
"limits": map[string]any{"max": 0, "allTriggers": false},
})
return
}
configs, err := h.scanBroadcaster.ListWebhookConfigs(user.DID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list webhooks: %v", err), http.StatusInternalServerError)
return
}
// Get tier limits — captains get unlimited access
maxWebhooks, allTriggers := 1, false
if h.isCaptain(r.Context(), user.DID) {
maxWebhooks, allTriggers = -1, true
} else if h.quotaMgr != nil {
_, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
tierKey := ""
if crew != nil {
tierKey = crew.Tier
}
maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
}
render.JSON(w, r, map[string]any{
"webhooks": configs,
"limits": map[string]any{
"max": maxWebhooks,
"allTriggers": allTriggers,
},
})
}
// HandleAddWebhook creates a new webhook configuration
func (h *XRPCHandler) HandleAddWebhook(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if h.scanBroadcaster == nil {
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
return
}
var req struct {
URL string `json:"url"`
Secret string `json:"secret"`
Triggers int `json:"triggers"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Validate HTTPS URL
u, err := url.Parse(req.URL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
http.Error(w, "invalid webhook URL: must be https", http.StatusBadRequest)
return
}
// Tier enforcement — captains get unlimited access
maxWebhooks, allTriggers := 1, false
if h.isCaptain(r.Context(), user.DID) {
maxWebhooks, allTriggers = -1, true
} else {
tierKey := ""
_, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
if crew != nil {
tierKey = crew.Tier
}
if h.quotaMgr != nil {
maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
}
}
// Check webhook count limit
count, err := h.scanBroadcaster.CountWebhooks(user.DID)
if err != nil {
http.Error(w, "failed to check webhook count", http.StatusInternalServerError)
return
}
if maxWebhooks >= 0 && count >= maxWebhooks {
http.Error(w, fmt.Sprintf("webhook limit reached (%d/%d)", count, maxWebhooks), http.StatusForbidden)
return
}
// Trigger bitmask enforcement: free users can only set TriggerFirst
if !allTriggers && req.Triggers & ^atproto.TriggerFirst != 0 {
http.Error(w, "trigger types beyond scan:first require a paid tier", http.StatusForbidden)
return
}
rkey, recordCID, err := h.scanBroadcaster.AddWebhookConfig(user.DID, req.URL, req.Secret, req.Triggers)
if err != nil {
http.Error(w, fmt.Sprintf("failed to add webhook: %v", err), http.StatusInternalServerError)
return
}
render.Status(r, http.StatusCreated)
render.JSON(w, r, map[string]any{
"rkey": rkey,
"cid": recordCID.String(),
})
}
// HandleDeleteWebhook deletes a webhook configuration
func (h *XRPCHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if h.scanBroadcaster == nil {
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
return
}
var req struct {
Rkey string `json:"rkey"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := h.scanBroadcaster.DeleteWebhookConfig(user.DID, req.Rkey); err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, fmt.Sprintf("failed to delete webhook: %v", err), http.StatusInternalServerError)
}
return
}
render.JSON(w, r, map[string]any{"success": true})
}
// HandleTestWebhook sends a test payload to a webhook
func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if h.scanBroadcaster == nil {
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
return
}
var req struct {
Rkey string `json:"rkey"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Look up webhook URL and secret
var webhookURL, secret, owner string
err := h.scanBroadcaster.db.QueryRow(`
SELECT url, secret, user_did FROM webhook_secrets WHERE rkey = ?
`, req.Rkey).Scan(&webhookURL, &secret, &owner)
if err != nil {
http.Error(w, "webhook not found", http.StatusNotFound)
return
}
if owner != user.DID {
http.Error(w, "unauthorized", http.StatusForbidden)
return
}
// Resolve handle if not available from auth context
userHandle := user.Handle
if userHandle == "" {
if _, handle, _, err := atproto.ResolveIdentity(r.Context(), user.DID); err == nil {
userHandle = handle
}
}
// Randomize vulnerability counts so each test shows a different severity color
critical := rand.IntN(3)
high := rand.IntN(5)
medium := rand.IntN(8)
low := rand.IntN(10)
total := critical + high + medium + low
// Build test payload
payload := WebhookPayload{
Trigger: "test",
HoldDID: h.scanBroadcaster.holdDID,
HoldEndpoint: h.scanBroadcaster.holdEndpoint,
Manifest: WebhookManifestInfo{
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
Repository: "test-repo",
Tag: "latest",
UserDID: user.DID,
UserHandle: userHandle,
},
Scan: WebhookScanInfo{
ScannedAt: time.Now().Format(time.RFC3339),
ScannerVersion: "atcr-scanner-v1.0.0",
Vulnerabilities: WebhookVulnCounts{
Critical: critical, High: high, Medium: medium, Low: low, Total: total,
},
},
}
payloadBytes, _ := json.Marshal(payload)
// Deliver test payload synchronously
success := h.scanBroadcaster.attemptDelivery(webhookURL, secret, payloadBytes)
render.JSON(w, r, map[string]any{
"success": success,
})
}
// registerWebhookHandlers registers webhook XRPC handlers on the router.
// Called from RegisterHandlers.
func (h *XRPCHandler) registerWebhookHandlers(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(h.requireAuth)
r.Get(atproto.HoldListWebhooks, h.HandleListWebhooks)
r.Post(atproto.HoldAddWebhook, h.HandleAddWebhook)
r.Post(atproto.HoldDeleteWebhook, h.HandleDeleteWebhook)
r.Post(atproto.HoldTestWebhook, h.HandleTestWebhook)
})
}
+116 -2
View File
@@ -49,6 +49,7 @@ type XRPCHandler struct {
scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners
httpClient HTTPClient // For testing - allows injecting mock HTTP client
quotaMgr *quota.Manager // Quota manager for tier-based limits
appviewDID string // DID of the trusted appview (for tier updates)
}
// PartInfo represents a completed part in a multipart upload
@@ -76,6 +77,11 @@ func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, broadcaster *EventBroa
}
}
// SetAppviewDID sets the trusted appview DID for tier update authentication.
func (h *XRPCHandler) SetAppviewDID(did string) {
h.appviewDID = did
}
// SetScanBroadcaster sets the scan broadcaster for dispatching scan jobs to scanners
func (h *XRPCHandler) SetScanBroadcaster(sb *ScanBroadcaster) {
h.scanBroadcaster = sb
@@ -209,11 +215,15 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
r.Get(atproto.HoldGetQuota, h.HandleGetQuota)
// Public tier list endpoint (no auth)
r.Get(atproto.HoldListTiers, h.HandleListTiers)
// Appview-authenticated endpoints (appview JWT auth)
r.Post(atproto.HoldUpdateCrewTier, h.HandleUpdateCrewTier)
// Scanner WebSocket endpoint (shared secret auth)
r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs)
// Webhook management endpoints (service token auth)
h.registerWebhookHandlers(r)
}
// HandleHealth returns health check information
@@ -1604,6 +1614,110 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, stats)
}
// HandleListTiers returns the hold's available tiers with storage quotas.
// This is a public endpoint (no auth) so the appview UI can display "3-10 GB depending on region."
func (h *XRPCHandler) HandleListTiers(w http.ResponseWriter, r *http.Request) {
if !h.quotaMgr.IsEnabled() {
render.JSON(w, r, map[string]any{"tiers": []any{}})
return
}
tierInfos := h.quotaMgr.ListTiers()
tiers := make([]map[string]any, 0, len(tierInfos))
for _, t := range tierInfos {
var quotaBytes int64
if t.Limit != nil {
quotaBytes = *t.Limit
}
tiers = append(tiers, map[string]any{
"name": t.Key,
"quotaBytes": quotaBytes,
"quotaFormatted": quota.FormatHumanBytes(quotaBytes),
"scanOnPush": t.ScanOnPush,
})
}
render.JSON(w, r, map[string]any{"tiers": tiers})
}
// HandleUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview.
// Auth: Bearer token signed by the appview's P-256 key (ES256 JWT).
func (h *XRPCHandler) HandleUpdateCrewTier(w http.ResponseWriter, r *http.Request) {
if h.appviewDID == "" {
http.Error(w, "appview DID not configured on this hold", http.StatusServiceUnavailable)
return
}
// Validate appview token
userDID, err := ValidateAppviewToken(r, h.appviewDID, h.pds.DID())
if err != nil {
slog.Warn("Appview token validation failed for updateCrewTier", "error", err)
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
// Parse request body
var req struct {
UserDID string `json:"userDid"`
TierRank int `json:"tierRank"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Verify the userDid in the body matches the sub claim
if req.UserDID != "" && req.UserDID != userDID {
// Use the body's userDid (the sub claim was the appview-specified user)
userDID = req.UserDID
}
if !atproto.IsDID(userDID) {
http.Error(w, "invalid userDid format", http.StatusBadRequest)
return
}
// Map tier rank to tier name
tierName := h.resolveTierByRank(req.TierRank)
if tierName == "" {
http.Error(w, "no tiers configured on this hold", http.StatusBadRequest)
return
}
// Update the crew member's tier
if err := h.pds.UpdateCrewMemberTier(r.Context(), userDID, tierName); err != nil {
slog.Error("Failed to update crew tier", "userDid", userDID, "tier", tierName, "error", err)
http.Error(w, fmt.Sprintf("failed to update tier: %v", err), http.StatusInternalServerError)
return
}
slog.Info("Updated crew tier via appview", "userDid", userDID, "tierRank", req.TierRank, "tierName", tierName)
render.JSON(w, r, map[string]string{"tierName": tierName})
}
// resolveTierByRank maps a 0-based rank index to a tier name from the quota config.
// If the rank exceeds the number of tiers, it clamps to the highest tier.
func (h *XRPCHandler) resolveTierByRank(rank int) string {
if !h.quotaMgr.IsEnabled() {
return ""
}
tiers := h.quotaMgr.ListTiers()
if len(tiers) == 0 {
return ""
}
if rank < 0 {
rank = 0
}
if rank >= len(tiers) {
rank = len(tiers) - 1
}
return tiers[rank].Key
}
// HoldUserDataExport represents the GDPR data export from a hold service
type HoldUserDataExport struct {
ExportedAt time.Time `json:"exported_at"`
+6 -69
View File
@@ -40,24 +40,12 @@ type TierConfig struct {
// Whether pushing triggers an immediate vulnerability scan.
ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."`
// Maximum number of webhook URLs a user can configure. 0 = none, -1 = unlimited.
MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhook URLs (0=none, -1=unlimited). Default: 1."`
// Whether all trigger types are allowed. Free tiers only get scan:first.
WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types. Free tiers only get scan:first."`
// Whether this tier earns a supporter badge on user profiles.
SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for members at this tier."`
}
// DefaultsConfig represents default settings.
type DefaultsConfig struct {
// Name of the tier assigned to new crew members.
NewCrewTier string `yaml:"new_crew_tier" comment:"Tier assigned to new crew members who don't have an explicit tier."`
// Whether the hold owner (captain) gets a supporter badge on their profile.
OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on the hold owner's profile."`
}
// Manager manages quota configuration and tier resolution
@@ -220,59 +208,6 @@ func (m *Manager) ScanOnPush(tierKey string) bool {
return false
}
// WebhookLimits returns the webhook limits for a tier.
// Returns (maxWebhooks, allTriggers). Default when no config: (1, false).
// Follows the same fallback logic as GetTierLimit.
func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bool) {
if !m.IsEnabled() {
return 1, false
}
if tierKey != "" {
if tier := m.config.TierByName(tierKey); tier != nil {
max := tier.MaxWebhooks
if max == 0 {
max = 1 // default
}
return max, tier.WebhookAllTriggers
}
}
// Fall back to default tier
if m.config.Defaults.NewCrewTier != "" {
if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil {
max := tier.MaxWebhooks
if max == 0 {
max = 1
}
return max, tier.WebhookAllTriggers
}
}
return 1, false
}
// BadgeTiers returns the names of tiers that have supporter badges enabled,
// ordered from highest rank to lowest. Includes "owner" first if
// defaults.owner_badge is true.
// Returns nil if quotas are disabled or no tiers have badges.
func (m *Manager) BadgeTiers() []string {
if !m.IsEnabled() {
return nil
}
var tiers []string
if m.config.Defaults.OwnerBadge {
tiers = append(tiers, "owner")
}
// Iterate in reverse: highest rank first
for i := len(m.config.Tiers) - 1; i >= 0; i-- {
if m.config.Tiers[i].SupporterBadge {
tiers = append(tiers, m.config.Tiers[i].Name)
}
}
return tiers
}
// TierCount returns the number of configured tiers
func (m *Manager) TierCount() int {
return len(m.tiers)
@@ -280,8 +215,9 @@ func (m *Manager) TierCount() int {
// TierInfo represents tier information for listing
type TierInfo struct {
Key string
Limit *int64
Key string
Limit *int64
ScanOnPush bool
}
// ListTiers returns all configured tiers with their limits, in rank order
@@ -299,8 +235,9 @@ func (m *Manager) ListTiers() []TierInfo {
}
limitCopy := limit
tiers = append(tiers, TierInfo{
Key: tier.Name,
Limit: &limitCopy,
Key: tier.Name,
Limit: &limitCopy,
ScanOnPush: tier.ScanOnPush,
})
}
return tiers
-27
View File
@@ -427,33 +427,6 @@ defaults:
}
}
func TestBadgeTiers_RankOrder(t *testing.T) {
cfg := &Config{
Tiers: []TierConfig{
{Name: "deckhand", Quota: "5GB"},
{Name: "bosun", Quota: "50GB", SupporterBadge: true},
{Name: "quartermaster", Quota: "100GB", SupporterBadge: true},
},
Defaults: DefaultsConfig{OwnerBadge: true},
}
m, err := NewManagerFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
tiers := m.BadgeTiers()
// Expected: owner first, then highest rank first
expected := []string{"owner", "quartermaster", "bosun"}
if len(tiers) != len(expected) {
t.Fatalf("got %v, want %v", tiers, expected)
}
for i := range expected {
if tiers[i] != expected[i] {
t.Errorf("tiers[%d] = %q, want %q", i, tiers[i], expected[i])
}
}
}
func TestListTiers_PreservesOrder(t *testing.T) {
cfg := &Config{
Tiers: []TierConfig{
+18 -51
View File
@@ -12,7 +12,6 @@ import (
"atcr.io/pkg/atproto"
"atcr.io/pkg/hold/admin"
"atcr.io/pkg/hold/billing"
holddb "atcr.io/pkg/hold/db"
"atcr.io/pkg/hold/gc"
"atcr.io/pkg/hold/oci"
@@ -105,7 +104,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
}
// Use shared DB for all subsystems
s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts, s.holdDB.DB)
s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts, s.holdDB.DB)
if err != nil {
return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err)
}
@@ -113,7 +112,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
s.broadcaster = pds.NewEventBroadcasterWithDB(holdDID, 100, s.holdDB.DB)
} else {
// In-memory mode (tests): each subsystem opens its own connection
s.PDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts)
s.PDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts)
if err != nil {
return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err)
}
@@ -188,26 +187,13 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
slog.Info("Quota enforcement disabled (no quota tiers configured)")
}
// Sync supporter badge tiers from quota config into captain record
if s.PDS != nil {
badgeTiers := s.QuotaManager.BadgeTiers()
badgeCtx := context.Background()
if _, captain, err := s.PDS.GetCaptainRecord(badgeCtx); err == nil {
if !stringSlicesEqual(captain.SupporterBadgeTiers, badgeTiers) {
captain.SupporterBadgeTiers = badgeTiers
if _, err := s.PDS.UpdateCaptainRecord(badgeCtx, captain); err != nil {
slog.Warn("Failed to sync supporter badge tiers", "error", err)
} else {
slog.Info("Synced supporter badge tiers from quota config", "tiers", badgeTiers)
}
}
}
}
// Create XRPC handlers
var ociHandler *oci.XRPCHandler
if s.PDS != nil {
xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, s.broadcaster, nil, s.QuotaManager)
if cfg.Server.AppviewDID != "" {
xrpcHandler.SetAppviewDID(cfg.Server.AppviewDID)
}
ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager)
// Initialize scan broadcaster if scanner secret is configured
@@ -240,7 +226,9 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
// Setup HTTP routes with chi router
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Maybe(middleware.Logger, func(r *http.Request) bool {
return r.URL.Path != "/xrpc/_health"
}))
if xrpcHandler != nil {
r.Use(xrpcHandler.CORSMiddleware())
@@ -252,6 +240,12 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
fmt.Fprintf(w, "This is a hold server. More info at https://atcr.io")
})
// Robots.txt - disallow crawling of all endpoints except root
r.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "User-agent: *\nAllow: /\nDisallow: /xrpc/\nDisallow: /admin/\n")
})
// Register XRPC/ATProto PDS endpoints
if xrpcHandler != nil {
slog.Info("Registering ATProto PDS endpoints")
@@ -283,20 +277,6 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
}
}
// Initialize billing manager (compile-time optional via -tags billing)
billingMgr := billing.New(s.QuotaManager, cfg.Server.PublicURL, cfg.ConfigPath())
if billingMgr.Enabled() {
slog.Info("Billing enabled (Stripe integration active)")
} else {
slog.Info("Billing disabled (not compiled or not configured)")
}
// Register billing endpoints (if configured and PDS available)
if s.PDS != nil && billingMgr.Enabled() {
billingHandler := billing.NewXRPCHandler(billingMgr, s.PDS, http.DefaultClient)
billingHandler.RegisterHandlers(r)
}
s.Router = r
return s, nil
@@ -334,11 +314,11 @@ func (s *HoldServer) Serve() error {
}
}
// Fetch appview metadata for branding (webhook embeds, posts)
if s.Config.Server.AppviewURL != "" {
meta, err := atproto.FetchAppviewMetadata(context.Background(), s.Config.Server.AppviewURL)
// Fetch appview metadata for branding (Bluesky posts)
if s.Config.Server.AppviewURL() != "" {
meta, err := atproto.FetchAppviewMetadata(context.Background(), s.Config.Server.AppviewURL())
if err != nil {
slog.Warn("Failed to fetch appview metadata, using defaults", "appview_url", s.Config.Server.AppviewURL, "error", err)
slog.Warn("Failed to fetch appview metadata, using defaults", "appview_url", s.Config.Server.AppviewURL(), "error", err)
} else {
s.PDS.SetAppviewMeta(meta)
slog.Info("Fetched appview metadata", "clientName", meta.ClientName, "clientShortName", meta.ClientShortName)
@@ -439,16 +419,3 @@ func (s *HoldServer) shutdown() {
logging.Shutdown()
}
// stringSlicesEqual returns true if two string slices have the same elements.
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+7
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"runtime/debug"
"syscall"
"github.com/spf13/cobra"
@@ -38,6 +39,12 @@ Use --config to specify a YAML configuration file.
Environment variables always override file values (SCANNER_ prefix).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Set a soft memory limit so the GC gets aggressive before the OOM
// killer intervenes. GOMEMLIMIT env var overrides this default.
if os.Getenv("GOMEMLIMIT") == "" {
debug.SetMemoryLimit(512 * 1024 * 1024) // 512 MiB
}
cfg, err := config.LoadConfig(configFile)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
+7 -2
View File
@@ -18,6 +18,10 @@ import (
"github.com/gorilla/websocket"
)
// httpClient is used for blob downloads and presigned URL requests
// with a timeout to prevent stalled connections from leaking memory.
var httpClient = &http.Client{Timeout: 5 * time.Minute}
// HoldClient manages the WebSocket connection to a hold service
type HoldClient struct {
holdURL string
@@ -95,6 +99,7 @@ func (c *HoldClient) connectOnce(cursor int64) error {
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
defer conn.Close()
c.mu.Lock()
c.conn = conn
@@ -229,7 +234,7 @@ func GetBlobPresignedURL(holdEndpoint, holdDID, digest, secret string) (string,
req.Header.Set("Authorization", "Bearer "+secret)
}
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get presigned URL: %w", err)
}
@@ -252,7 +257,7 @@ func GetBlobPresignedURL(holdEndpoint, holdDID, digest, secret string) (string,
// DownloadBlob downloads a blob from a presigned URL to a local file
func DownloadBlob(presignedURL, destPath string) error {
resp, err := http.Get(presignedURL)
resp, err := httpClient.Get(presignedURL)
if err != nil {
return fmt.Errorf("failed to download blob: %w", err)
}
+1 -1
View File
@@ -72,7 +72,7 @@ func setScannerDefaults(v *viper.Viper) {
v.SetDefault("hold.secret", "")
// Scanner defaults
v.SetDefault("scanner.workers", 2)
v.SetDefault("scanner.workers", 1)
v.SetDefault("scanner.queue_size", 100)
// Vuln defaults
+17 -2
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
scanner "atcr.io/scanner"
@@ -34,7 +35,8 @@ import (
var (
vulnDB vulnerability.Provider
vulnDBLock sync.RWMutex
vulnDBLoaded time.Time // when the current vulnDB was loaded
vulnDBLoaded time.Time // when the current vulnDB was loaded
vulnDBScans atomic.Int64 // scan counter for periodic reload
)
// vulnDBRefreshAge is how long a cached DB is considered fresh.
@@ -140,7 +142,17 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
// Double-check after acquiring write lock
if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge {
return vulnDB, nil
// Periodic reload: close and reopen DB every 50 scans to flush
// SQLite's page cache and mmap region.
n := vulnDBScans.Add(1)
if n%50 == 0 {
slog.Info("Periodic vulnDB reload to release memory", "scans", n)
vulnDB.Close()
vulnDB = nil
// Fall through to reload below
} else {
return vulnDB, nil
}
}
slog.Info("Loading Grype vulnerability database", "path", vulnDBPath)
@@ -178,6 +190,9 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
"built", status.Built,
"schemaVersion", status.SchemaVersion)
if vulnDB != nil {
vulnDB.Close()
}
vulnDB = store
vulnDBLoaded = time.Now()
return vulnDB, nil
+3 -2
View File
@@ -29,13 +29,14 @@ func generateSBOM(ctx context.Context, ociLayoutDir string) (*sbom.SBOM, []byte,
if err != nil {
return nil, nil, "", fmt.Errorf("failed to load OCI image: %w", err)
}
defer img.Cleanup()
if err := img.Read(); err != nil {
img.Cleanup()
return nil, nil, "", fmt.Errorf("failed to read OCI image: %w", err)
}
// Wrap in Syft source
// Wrap in Syft source — src.Close() calls img.Cleanup() internally,
// so we don't defer img.Cleanup() separately.
src := stereoscopesource.New(img, stereoscopesource.ImageConfig{
Reference: ociLayoutDir,
})
+20 -6
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"os"
"runtime"
"strings"
"sync"
"time"
@@ -93,15 +94,27 @@ func (wp *WorkerPool) worker(ctx context.Context, id int) {
"repository", job.Repository,
"error", err)
wp.client.SendError(job.Seq, err.Error())
continue
} else {
wp.client.SendResult(job.Seq, result)
slog.Info("Scan job completed",
"worker_id", id,
"repository", job.Repository,
"vulnerabilities", result.Summary.Total)
}
wp.client.SendResult(job.Seq, result)
// Free large scan artifacts and trigger GC before the cooldown
// so memory is reclaimed between jobs. Syft/Grype allocate heavily
// and Go's GC needs idle time to catch up under sustained load.
result = nil
runtime.GC()
slog.Info("Scan job completed",
"worker_id", id,
"repository", job.Repository,
"vulnerabilities", result.Summary.Total)
// Cooldown between scans to reduce sustained memory pressure
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
}
}
@@ -168,6 +181,7 @@ func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*sc
result.VulnDigest = vulnDigest
result.Summary = &summary
}
sbomResult = nil // release SBOM catalog for GC
duration := time.Since(startTime)
slog.Info("Scan pipeline completed",