Skip to content

feat(meter): metered billing infrastructure for fence + future products #135

@ssilvius

Description

@ssilvius

Goal

Add Polar metered-billing infrastructure to platform so fence (and future metered products) can report billable events with audit-clean idempotency. Polar audits accounts and closes them for misreporting usage; this PR series exists because the contract is signed (vault-2026/projects/smugglr/fence/contracts/platform-fence-contract.md) and fence v0 will start calling /api/v1/meter once it ships.

Architecture decisions are captured in legion reflection 019e6514. Do NOT relitigate any of:

  • org-as-customer (not user-as-customer)
  • no agent table, no AAP token verifier, no agent registration
  • shell bearers are opaque (no PASETO inside api-key, no signed claims)
  • @polar-sh/better-auth's createCustomerOnSignUp is OFF; afterCreateOrganization is ON
  • Polar meters are created manually in the dashboard, NOT by code

Requirements

1. Schema migration (0005_meter_billing.sql)

  • New table meter_event per the spec in 019e6514
  • New column organization.polar_customer_id (TEXT, UNIQUE, nullable until backfill, then NOT NULL)
  • Wrangler creates via wrangler d1 migrations create rafters meter_billing

2. Org -> Polar customer wiring

  • Set createCustomerOnSignUp: false at apps/web/src/auth.ts:86
  • Add afterCreateOrganization hook that calls Polar SDK to create a customer with externalId = org.id, writes polar_customer_id back to org row
  • Idempotent: if org already has polar_customer_id, skip
  • One-shot backfill script for any existing orgs (probably zero at this point)

3. POST /api/v1/meter endpoint

  • Auth: better-auth api-key plugin (registered separately, see Plugin wiring)
  • Headers: Authorization: Bearer ak_<random>, Idempotency-Key: <receipt_id>
  • Body Zod: { event_name, event_timestamp, metadata }
  • INSERT OR IGNORE on idempotency_key into meter_event with polar_customer_id resolved from the api-key's referenceId (the org)
  • Returns 202 { status: 'recorded' | 'already_recorded' }
  • Writes audit_log row via ledger

4. api-key plugin registration

  • Register better-auth api-key plugin in apps/web/src/auth.ts
  • customKeyGenerator returns ak_<base62(48)> -- opaque random, no signature
  • Org-scoped (referenceId = organization.id)
  • Permissions vocabulary at v0: 'fence:meter' (extensible later)
  • Surface for create/list/revoke via better-auth's built-in endpoints

5. Outbound drainer cron

  • New cron in wrangler.jsonc: */5 * * * * -> drainMeterOutbox
  • Selects up to 100 pending/failed rows with attempts < 7
  • Calls polarClient.events.ingest with the event payload
  • Success: polar_status='sent', polar_event_id, sent_at
  • Failure: attempts++, exponential backoff via last_attempt_at math
  • After 7 attempts: polar_status='dropped' + legion signal + structured log warning
  • Dispatch handler in src/index.ts (existing cron handler test will catch wrangler/handler mismatch)

6. Reconciliation cron

  • New cron 0 3 * * * -> reconcileMeterToPolar
  • Lists Polar's events for last 24h via Polar SDK
  • Compares count + per-customer totals to meter_event WHERE event_timestamp in window
  • Drift > 0 -> structured log warning + audit_log row
  • Drift threshold (e.g. > 1%) -> legion signal me

7. /.well-known/agent-configuration

  • Static JSON at /.well-known/agent-configuration describing platform's capabilities
  • Speaks AAP discovery convention without implementing AAP token format
  • Lists: PASETO bearer (future), session cookie (browser), api-key bearer (shell/agent), referenceable endpoints
  • One-line route in apps/web/src/app.ts pointing at a static handler

8. Tests

  • Unit (.test.ts): meter_event Zod validation, drainer backoff math, reconcile drift math, idempotency-key INSERT OR IGNORE behavior
  • E2E (.e2e.ts): meter ingest happy path, idempotency replay returns 'already_recorded', api-key revoke kills next request

Out of Scope (do not creep)

  • AAP token verification (defer until AAP v1.0 stabilizes)
  • Agent table / agent registration / agent lifecycle
  • Self-verifying PASETO bearers for long-lived tokens (api-key plugin replaces this need)
  • ctrl dashboard for meter events / api-key management (separate PR after meter ships)
  • Polar dashboard meter creation -- Sean does this manually, gives event_names to hard-code
  • Per-tier / per-customer rate limits (api-key plugin supports it, just not configured at v0)
  • Stub /api/v1/meter returning 503 during the MVP -> meter PR gap window (deploy this AFTER MVP-deploy, fence is not shipping in between)

Files Affected

  • apps/web/src/db/migrations/0005_meter_billing.sql (new, via wrangler)
  • apps/web/src/db/schema/meter.ts + meter.zod.ts (new)
  • apps/web/src/auth.ts (org plugin hooks + api-key plugin registration + createCustomerOnSignUp false)
  • apps/web/src/app.ts (route mount for /api/v1/meter + /.well-known/agent-configuration)
  • apps/web/src/routes/meter.ts (new)
  • apps/web/src/lib/meter/drain.ts (new, called from cron)
  • apps/web/src/lib/meter/reconcile.ts (new, called from cron)
  • apps/web/src/lib/meter/polar-ingest.ts (new, the SDK wrapper)
  • apps/web/src/cron/drain-meter.ts + reconcile-meter.ts (new)
  • apps/web/src/index.ts (cron dispatch additions)
  • apps/web/wrangler.jsonc (new cron entries)
  • tests/api/routes/meter.test.ts + meter.spec.ts (new)
  • tests/api/lib/meter/*.test.ts (new)
  • tests/flows/meter.e2e.ts (new)

Done When

  • Migration 0005 applied to remote D1
  • Sean has created the v0 Polar meters in dashboard and provided event_names
  • An api-key minted for a test org can hit /api/v1/meter and the event appears in Polar within one drainer cycle
  • Replaying the same Idempotency-Key returns 'already_recorded' and creates no Polar event
  • Revoking the api-key (enabled=false) makes the next request return 401
  • Reconciliation cron runs for one cycle with drift = 0
  • /.well-known/agent-configuration returns valid JSON describing our auth surfaces
  • e2e smoke green
  • No agent table or AAP token verifier in the diff (this PR is explicit about what is NOT here)

Context

  • Architecture decisions: legion reflection 019e6514
  • Platform-fence contract: vault-2026/projects/smugglr/fence/contracts/platform-fence-contract.md
  • Fence handoff: legion 019e5123
  • Polar adapter docs reading: web search this session
  • better-auth api-key plugin source: github.com/better-auth/better-auth/tree/main/packages/api-key

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions