Files
at-container-registry/docs/BILLING_REFACTOR.md

15 KiB

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:
    {
      "$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

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

STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

Hold config changes

# 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

-- 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).