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
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
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/meteronce it ships.Architecture decisions are captured in legion reflection 019e6514. Do NOT relitigate any of:
Requirements
1. Schema migration (0005_meter_billing.sql)
meter_eventper the spec in 019e6514organization.polar_customer_id(TEXT, UNIQUE, nullable until backfill, then NOT NULL)wrangler d1 migrations create rafters meter_billing2. Org -> Polar customer wiring
createCustomerOnSignUp: falseat apps/web/src/auth.ts:86afterCreateOrganizationhook that calls Polar SDK to create a customer with externalId = org.id, writes polar_customer_id back to org row3. POST /api/v1/meter endpoint
Authorization: Bearer ak_<random>,Idempotency-Key: <receipt_id>4. api-key plugin registration
customKeyGeneratorreturnsak_<base62(48)>-- opaque random, no signature5. Outbound drainer cron
*/5 * * * *-> drainMeterOutbox6. Reconciliation cron
0 3 * * *-> reconcileMeterToPolar7. /.well-known/agent-configuration
8. Tests
Out of Scope (do not creep)
Files Affected
Done When
Context