Skip to content

Re-review: admin personas + install wizard + mail UI + Network carve - #9

Open
keithfawcett wants to merge 274 commits into
pre-ultrareview-2from
main
Open

keithfawcett wants to merge 274 commits into
pre-ultrareview-2from
main

Conversation

@keithfawcett

Copy link
Copy Markdown
Contributor

This PR exists purely as an ultrareview target — it represents everything on main that hasn't been reviewed since the last pass.

Scope

14 commits between `532d7c0` (last reviewed regression tests) and `HEAD`:

  • `fa6fcb1` Network code carved out of OSS to a separate private repo
  • `ea41a6f` Partner invite + magic-link signin
  • `abbb0dc` Mailer dev-mailbox dropped; Postmark or console
  • `999b050` Admins no longer mint partner credentials or links
  • `911fc5c` Partner revocation (session kill, attribution skip, router fraud-flag)
  • `bce56f0` Revoke notifications + optional reason + signin handling
  • `acf8eb5` UI-managed program settings (name + support email)
  • `c28f197` Admin personas, SMTP support, first-run install wizard
  • `fa0d0c7` UI-managed SMTP/Postmark with encryption at rest
  • `a922e30` Install split into 3-step wizard
  • `59526b7` Docs refresh

Plus three small CI fixes (`63f016d`, `ba67b70`, `a6db3a6`).

Areas of highest concern for review

  • Generalized Session + MagicLinkToken schema (partnerId → principalKind/principalId) — backfill correctness, FK loss, principal resolution branching
  • First-run `/install` — 409 guard against second installer, rate limit, transactional admin + program settings creation
  • `SECRETS_ENCRYPTION_KEY` — AES-GCM envelope, fallback in dev, key rotation story
  • Mail resolution order — UI config → env → console fallback; stale credential races on rotation
  • Revoke guards — last-active-admin protection, can't-revoke-self, session kill atomicity

Not for merge.

keithfawcett added a commit that referenced this pull request Apr 25, 2026
…race

Three security / lockout fixes from the ultrareview re-pass (PR #9):

1. POST /auth/signin for a revoked partner was sending the
   partner_revoked notice email every time. An attacker could spam
   any known partner's inbox by POSTing their email repeatedly.
   Now: signin for a revoked partner is silent (no email). Revoke
   flow already sends a one-time notification at revoke time with
   the admin-provided reason — that's the only on-the-record
   touchpoint.

2. POST /install's "no admin exists" check was outside the tx.
   Two concurrent installers could both pass the check and both
   create admin rows. Now the check happens inside a transaction
   under a pg_advisory_xact_lock keyed to the install path, so
   concurrent calls serialize and the loser 409s. Also tightened
   the guard to block on ANY admin row (not just activated) so a
   second installer can't slip in while the first magic-link is
   still pending.

3. POST /admins/:id/revoke's "last active admin" guard was outside
   the transaction. Two concurrent revokes of the only two active
   admins both saw count=2, both proceeded, and both committed —
   leaving zero admins and locking everyone out. Now the guard runs
   inside a transaction with SELECT ... FOR UPDATE on the active-
   admins set, so concurrent revokes serialize and the loser 409s
   on cannot_revoke_last_active_admin. Also added revoked-admin
   guard to /admins/:id/invite (can't resend to a revoked account)
   and cleaned up the unused activeAdminCount helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
keithfawcett added a commit that referenced this pull request Apr 25, 2026
Four correctness fixes from ultrareview PR #9:

  - Partner revocation now also flips revokedAt on every ApiKey row
    tied to that partner. Previously sessions were killed but partner-
    scoped API keys lived on, so a revoked partner kept programmatic
    access via the SDK / curl.

  - POST /install is retryable on partial failure. The admin row is
    created inside a tx; the following mail-config save + invite
    email happen in a compensating try/catch that undoes the admin +
    magic-link token on any downstream error. Without this, a Postmark
    outage during first install left the instance permanently 409'd.

  - Install schema now rejects mail.kind='smtp'|'postmark' without a
    from address (plus host/serverToken for the respective transport).
    Previously the install "succeeded" and then the activation email
    silently failed because the mailer had no sender.

  - attribution.ts comment updated to match behavior: revoked-partner
    filtering skips them regardless of event timing, for both live
    event webhooks and backlog replays. Earlier the docstring
    suggested "events after revokedAt" but the code filtered more
    aggressively. Code is what we want; comment was lying.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
keithfawcett added a commit that referenced this pull request Apr 25, 2026
Four validation / UX fixes from ultrareview PR #9:

  - POST /partners pre-checks for a duplicate email and 409s with
    email_taken instead of letting the unique-constraint violation
    surface as a generic 500. A race path (two concurrent inserts
    passing the pre-check) is caught via pg error code 23505 and also
    maps to 409.

  - saveMailSettings now refuses kind='smtp' with an empty host and
    kind='postmark' without a serverToken. Both used to save garbage
    that would blow up at first email. MailSettingsValidationError
    maps to a 400 with a field pointer in the /config/mail route.

  - MagicLanding invalidates the install-status react-query cache on
    successful verify so the first-run admin lands on / after
    clicking their activation link. Before, the cache was keyed
    staleTime:Infinity and still said needsSetup=true from boot —
    / would redirect right back to /install until a hard refresh.

  - admin_accounts migration's down() dropped a non-existent index on
    MagicLinkToken (up() only ever added the index on Session). Dropped
    the stray dropIndex call so rollbacks succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
keithfawcett and others added 27 commits April 25, 2026 22:33
Adds github source ref to each component (api, router, portal) and
fixes the ingress rules: portal routes / by default, api gets /api,
router gets /r as a path prefix until the dedicated subdomain is wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DO Managed Postgres serves a CA-signed cert that's not in Node's default
trust store. With pg-node, sslmode=require alone fails the chain check
and the migration runner aborts before the api can start.

Adds sslFromConnectionString helper used by both the runtime db factory
and the knex migration runner. Maps:
  sslmode=require | no-verify       → encrypted, rejectUnauthorized=false
  sslmode=verify-ca | verify-full   → encrypted, full chain check
  no sslmode                        → no ssl (local dev)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pg-connection-string >= v2.7 treats sslmode=require as verify-full and
overrides our explicit { rejectUnauthorized: false } config when both
are present (URL parsing happens after our config is set, so it wins).

Strip sslmode from the URL when we manage ssl ourselves. The connection
remains TLS-encrypted; we just opt out of the chain check that would
otherwise fail on managed providers' self-signed CAs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(db): multi-tenant foundation + RLS

Three new migrations and matching type updates lay the groundwork for
multi-tenant deployments while keeping single-tenant self-host working
identically (just with tenantId='default' baked in).

20260507000000_multi_tenant
  - New Tenant table; seeded 'default' tenant.
  - tenantId column on every data table (Partner, Campaign, Link, Click,
    Identity, Event, Attribution, Commission, Payout, ApiKey, Config,
    Admin, MagicLinkToken, Session, WebhookEndpoint, WebhookDelivery).
  - Backfill existing rows to the default tenant.
  - Re-scope unique constraints to be per-tenant: Partner.email,
    Admin.email, Link.linkKey, Config.(key→tenantId,key).

20260507010000_rls_policies
  - PlatformAdmin table (cross-tenant Coherence support staff).
  - RLS ENABLE + FORCE on every tenanted table.
  - Policy: row visible iff tenantId matches `app.tenant_id` GUC OR
    `app.platform_admin` GUC = 'on'.
  - Tenant table: row visible iff its id matches app.tenant_id (or
    platform admin). Same for PlatformAdmin.
  - Policies use COALESCE / current_setting(..., true) so an unset GUC
    returns 0 rows (default deny) instead of erroring.

20260507020000_app_role
  - Provisions a non-superuser openpartner_app role from
    OPENPARTNER_APP_DB_PASSWORD. Postgres bypasses RLS for superusers
    and BYPASSRLS roles regardless of FORCE, so RLS only protects when
    the app connects as a constrained role.
  - Grants DML (no DDL) on every tenanted table.
  - Idempotent: rotates password if the role already exists.
  - Skipped (with notice) when OPENPARTNER_APP_DB_PASSWORD is unset —
    self-host installs that don't need RLS isolation can run the app as
    the same role as migrations.

Migration runner sets `row_security = off` at session start so DDL
runs unrestricted.

Verified: connecting as openpartner_app, queries return 0 rows when
app.tenant_id is unset or mismatched, and only the in-scope tenant's
rows when set correctly. Platform-admin override works.

Types: every Row interface gained `tenantId: string`; new TenantRow,
PlatformAdminRow types and DEFAULT_TENANT_ID constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): tenancy middleware + connection split (admin vs app pools)

Two knex instances now:
  db (admin pool, DATABASE_URL)
    - migrations, signup, platform-admin tooling, jobs that need
      cross-tenant access
    - bypasses RLS (superuser/owner role)

  appDb (app pool, DATABASE_URL_APP if set, else DATABASE_URL)
    - normal request handling. When pointed at the openpartner_app role
      every query is subject to RLS.
    - per-request transaction in tenancy middleware sets
      `app.tenant_id` (and optionally `app.platform_admin = 'on'`)
      so RLS policies match correctly.

OPENPARTNER_TENANCY env (defaults 'single'):
  single  — every request runs as tenantId = DEFAULT_TENANT_ID. Self-host.
  multi   — path-based tenant resolution (/t/<slug>/...). Reserved
            slugs (www, api, app, signup, etc.) reject.

tenantMiddleware:
  - resolves tenantId for the request
  - opens a transaction on appDb
  - stamps req.db, req.tenantId, req.tenantSlug
  - awaits response finish before committing/rolling back so handler
    queries land in the right transaction context.

Routes will switch from `db('Partner')...` to `req.db('Partner')...`
and add `tenantId: req.tenantId` to inserts. That refactor is the next
commit; this one just lays the wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tenancy): add tenantOf(req) helper for route handler ergonomics

* docs(multi-tenant): handoff guide for the in-progress refactor

Architecture decisions, what's committed, file-by-file refactor plan,
test fixup plan, and how to resume. Read this first before continuing
the multi-tenant work on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): route + helper refactor for tenant-scoped req.db

Section A + B + C + E of the multi-tenant refactor: every route handler
now uses tenantOf(req) for a per-request transaction with app.tenant_id
pinned. Helpers (auth-sessions, auth.resolvePrincipal, config, mail-
settings, mailer, attribution, payouts, usage-billing, webhook-dispatcher)
take Knex + tenantId as parameters. tenantMiddleware is mounted in
app.ts; install + metrics stay public above it. Stripe webhook resolves
tenantId from event metadata and runs each event in appDb.transaction
with SET LOCAL app.tenant_id. Scheduler iterates active tenants per
tick. Typecheck passes.

What this leaves: section D (public /signup), F (test seed updates so
35 of 64 currently-failing tests go green), G (multi-tenant isolation
tests), H (env config + ops). Documented in docs/multi-tenant-refactor.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): public /signup + test seed tenantId fixes

Section D + F of the multi-tenant refactor.

D — POST /signup creates a Tenant + first Admin and emails an activation
magic link. Public, IP rate-limited (10/min), gated by slug validation
(/^[a-z0-9-]{3,30}$/, not in RESERVED_SLUGS, not already taken). Mounted
before tenantMiddleware in app.ts and uses the privileged db. Multi-mode
only — single-mode operators use /install.

F — every direct db().insert() in integration.test.ts, regressions.test.ts,
stripe-webhook.test.ts, and webhooks.test.ts now stamps tenantId:
DEFAULT_TENANT_ID. Test setups force OPENPARTNER_TENANCY=single. Cannot
verify against a live Postgres in this session; flagged as DONE BUT NOT
VALIDATED in docs/multi-tenant-refactor.md so the next pass runs the
suite first.

Handoff doc updated with current branch state and remaining work
(sections G, H + test validation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ops): multi-tenant env + docker + DO + docs

Section H of the multi-tenant refactor.

- .env.example: OPENPARTNER_TENANCY, OPENPARTNER_APP_DB_PASSWORD,
  DATABASE_URL_APP with explanatory comments.
- docker-compose.yml: mount docker/initdb so postgres provisions the
  openpartner_app role on first boot. Role is NOLOGIN if no password
  set so RLS isolation can still be exercised via SET ROLE in tests.
- .do/app.yaml: add OPENPARTNER_TENANCY=multi (default for hosted),
  DATABASE_URL_APP + OPENPARTNER_APP_DB_PASSWORD secrets on the api
  component.
- docs/deploy-production.md: rows for the new secrets in the env
  table; new "Multi-tenant rollout" subsection covering URL routing,
  signup, RLS engagement, Stripe webhook tenant resolution, reserved
  slugs, and the migration path from single-tenant.

The route, helper, signup, and stripe-webhook refactors plus this
ops layer make the multi-tenant branch deployable. What's left in
docs/multi-tenant-refactor.md is section G (live-Postgres isolation
tests) — needs a real DB to write meaningfully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tenancy): bypass RLS on privileged db, commit trx pre-response, add isolation tests

Section G of the multi-tenant refactor + two real bugs the existing
suite surfaced once it ran against a real Postgres.

Bug 1: privileged db was subject to FORCE RLS. The migration role
owns the tenanted tables but FORCE RLS still gates the owner unless
row_security is explicitly off or app.tenant_id is set. Without
either, /metrics, /signup, the stripe-webhook tenant resolver, the
scheduler, and every test's direct cleanup query silently saw zero
rows. Fixed by adding bypassRls: true to createDb (sets row_security
= off in afterCreate) and turning it on for the privileged pool. The
appDb (tenant pool) keeps RLS engaged.

Bug 2: tenantMiddleware committed the per-request transaction on
res.on('finish'), which fires AFTER the response is sent. Tests doing
`await request(app).post(...)` then `await db(...).insert(...)` raced
the commit and got FK violations because the route's writes weren't
yet visible. Fixed by patching res.json/send/end so the trx commits
(or rolls back on 5xx) before any byte goes out. Belt-and-suspenders
res.on('close') still rolls back if the patched methods are bypassed.

Section G: apps/api/src/__tests__/multi-tenant.test.ts — 9 tests
that connect as openpartner_app via SET ROLE inside a privileged-pool
transaction (so RLS engages because openpartner_app has neither
BYPASSRLS nor superuser). Covers default deny, per-tenant visibility,
WITH CHECK rejection on cross-tenant inserts, platform_admin override,
session isolation, and the Tenant table self-policy. Suite skips
cleanly with a warning if the openpartner_app role isn't provisioned.

Stripe webhook tenant resolution: customer/invoice/charge events that
don't carry our metadata now fall back to a local Identity → Click
lookup so checkout-stitched customers still route to the right tenant
on subsequent invoice.paid / charge.refunded.

Result: 73/73 tests pass against the docker-compose postgres.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(network): creator self-signup + vendor↔Network protocol

Three pieces, designed so the same code paths cover hosted multi-tenant
and self-host:

1. Public POST /partner-signup (apps/api/src/routes/partner-signup.ts).
   Tenant-scoped, IP rate-limited, creates a Partner row + magic link.
   Honors a per-tenant partner_signup config (auto_approve vs
   require_review, with disabled override). On hosted multi-tenant the
   URL is /t/<slug>/partner-signup; on self-host it's /partner-signup.

2. Vendor-side Network client (apps/api/src/network-client.ts) +
   NetworkOutbox migration. Fire-and-forget POSTs to /partners/upsert
   on creator events (signup, admin invite, revoke); failures persist
   to the outbox and the scheduler drains them every 5 min with
   exponential backoff (~24h max). vendorToken stored AES-GCM
   encrypted in Config (network_membership), never returned by GET
   /config/network. backfillPartners(...) reconciles a vendor's
   existing roster when they enable Network membership later — the
   Network dedups on email and returns alreadyExisted=true for
   creators who joined another vendor first.

3. Network protocol spec (docs/network-protocol.md). Defines the
   /vendors/register, /partners/upsert, /vendors/backfill-partners,
   and /vendors/me/heartbeat surface that openpartner-network
   implements. Spells out the identity model (vendorId,
   vendorPartnerId, networkCreatorId), auth rotation, and the
   late-join reconciliation behavior.

Wired into existing flows:
- POST /partners (admin invite) + /partners/:id/revoke push to Network
  when membership is enabled. autoEnroll gates new-partner upserts;
  revokes mirror unconditionally so a Network-known creator stops
  being matched after the vendor cuts them off.
- Settings router exposes GET/POST /config/network,
  POST /config/network/backfill, and GET/POST /config/partner-signup.
- Scheduler runs network-outbox-drain every 5 min per active tenant.

Tests (apps/api/src/__tests__/network-and-signup.test.ts, 9 cases)
spin up an in-process HTTP receiver to act as the Network and verify:
signup without Network is silent; with Network on stamps
networkCreatorId on Partner.metadata.network; with Network down
enqueues outbox; drain retries succeed; require_review still pushes
status=pending; admin invite + revoke push; late-join backfill flips
preExisting=true for emails the Network already knew; GET
/config/network never leaks the vendor token.

82/82 tests pass against the docker-compose postgres.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(network): vendor-side onboarding integration

Wires the openpartner vendor side to the openpartner-network
self-serve onboarding flow.

network-client.ts: signupWithNetwork() POSTs to /vendors/signup;
completeNetworkConnect() POSTs to /vendors/verify-and-issue-token.
Failures surface immediately to the admin (no outbox queueing — a
failed signup is something the admin retries by hand).

routes/settings.ts: POST /config/network/start-connect mints a fresh
scoped key with NETWORK_FEDERATION_SCOPES, calls signupWithNetwork
with inferred instanceUrl + portalCallbackUrl, stashes partial state
in network_membership Config (enabled=false until verify lands).
POST /config/network/complete-connect consumes the magic-link ntoken,
calls Network /vendors/verify-and-issue-token, saves the returned
vendorToken with enabled=true. Same shape works for hosted multi-
tenant tenants (slug-aware URL inference) and self-host (request host).

routes/signup.ts: hosted multi-tenant signup auto-calls
signupWithNetwork after Tenant/Admin creation when NETWORK_URL env
is set. Best-effort: a Network outage doesn't fail the signup; the
admin can finish later via Settings → Network → Connect button.
Returns network: { status, vendorId } in the signup response so the
portal can show the right next-step UI.

.env.example: NETWORK_URL added with explanatory comment.

82/82 vendor-side tests still pass (no regressions; the new endpoints
are additive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(portal): vendor admin Network UI — connect, offerings, requests

Closes the gap where vendors had backend wiring for Network membership
but no UI to use it. Without this, Network was invisible to vendor
admins on hosted multi-tenant + self-host.

Backend (apps/api):
- network-client.ts: NetworkProxyError + networkProxy.{listOfferings,
  createOffering, updateOffering, deleteOffering, listRequests,
  approveRequest, rejectRequest, whoami}. Decrypts the vendor token
  from network_membership Config and proxies to Network endpoints
  with the right bearer.
- routes/settings.ts: /admin/network/{me,offerings,offerings/:id,
  requests,requests/:id/approve,requests/:id/reject}. Each is a thin
  wrapper around networkProxy.* that turns NetworkProxyError into
  the appropriate HTTP status. Required because the vendorToken is
  a server-side secret — the portal can't hold it.

Portal (apps/portal):
- pages/admin/Network.tsx: connection status, contact-email/display-
  name form for the Connect button, autoEnroll toggle, backfill
  panel for late-join reconciliation.
- pages/admin/NetworkComplete.tsx: handles ?ntoken= callback from
  the Network onboarding email; calls /config/network/complete-connect,
  redirects to /admin/network on success. StrictMode-safe (one-shot
  guard).
- pages/admin/NetworkOfferings.tsx: list + create + publish/unpublish
  + delete. Campaign dropdown pulls from /campaigns. Form fields:
  title, description, productUrl, campaign, commission summary,
  cookie window.
- pages/admin/NetworkRequests.tsx: pending requests list with creator
  bio + pitch; approve dispatches federation (creates Partner +
  Link on this instance); reject + status filter (pending /
  approved / rejected / cancelled).

Wired into App.tsx routes + a new "Network" sidebar section
(Connection, Offerings, Requests).

Typecheck passes; portal builds (318 KB JS, 92 KB gzip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the metering loop on the 3% Network fee. Previously the
openpartner main repo stamped Partner.metadata.network.creatorId
when a partner came through the Network, but never aggregated the
resulting payouts or surfaced them to anyone. The Network's billing
charged the flat $29 only.

usage-billing.ts: aggregateNetworkOriginatedPayouts(db, since, until)
sums Payout.amount where Payout.status='paid' AND
Partner.metadata.network.creatorId is not null AND completedAt is in
window (since, until]. Tenant scope provided by the caller.

network-client.ts: reportNetworkPayoutsToNetwork(db, tenantId)
- Loads network_membership; bails reason='network_not_configured' if
  not enabled.
- Aggregates the period's total via the helper above, keyed off a
  Config high-water mark (CONFIG_KEYS.LastNetworkPayoutsReportedAt).
- POSTs { amountUsd, sinceIso, untilIso } to the Network's
  /vendors/me/report-payouts with the vendor token bearer.
- On 2xx, advances the high-water mark. On Network failure, leaves
  the mark untouched so the next tick re-includes the same payouts;
  the Network's identifier dedups at Stripe.
- On amount=0, advances the mark anyway (don't re-scan empty windows).

scheduler.ts: new cron 'network-payouts-report' at 03:30 UTC daily,
per active tenant via the existing forEachActiveTenant iterator.
Sits alongside usage-report (03:15) so they don't compete for the
same window.

config.ts: CONFIG_KEYS.LastNetworkPayoutsReportedAt added.

Tests (src/__tests__/network-payouts-report.test.ts, 8 cases) spin up
a local HTTP receiver acting as the Network endpoint. Cover:
- aggregateNetworkOriginatedPayouts:
  - sums only paid payouts on Network-flagged Partners
  - excludes pending; excludes payouts on direct partners
  - respects (since, until] bounds
- reportNetworkPayoutsToNetwork:
  - skips when membership not enabled
  - zero amount: skip + advance mark
  - happy path: right total, right bearer, mark advances
  - Network 5xx: NO mark advance (so retry catches it)
  - Subsequent run only includes new payouts (mark works)

90/90 vendor-side tests still pass.

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last gap in the Network billing flow: vendor admins now
have a UI to subscribe / manage / view status. Previously they'd have
needed to curl /api/vendors/me/billing/checkout directly.

Backend (apps/api):
- network-client.ts: extends networkProxy with getBilling,
  createCheckout, openPortal — same proxy pattern as the existing
  Network admin proxies (vendor token decrypted server-side, body
  forwarded through). Returns the typed Billing payload + Stripe
  URL responses.
- routes/settings.ts: GET /admin/network/billing,
  POST /admin/network/billing/checkout (validates successUrl +
  cancelUrl), POST /admin/network/billing/portal (validates
  returnUrl). Each is a thin wrapper around networkProxy.* with
  the standard NetworkProxyError → HTTP status mapping.

Portal (apps/portal):
- pages/admin/NetworkBilling.tsx: branches on bundledWithMainPlan.
  Hosted vendors see a "bundled with Flex/Revshare" panel.
  Self-hosted see a status card (subscription state, current
  period end, $29 + 3% recap) plus EITHER a Subscribe panel
  (opens Stripe Checkout) or a Manage panel (opens Stripe Customer
  Portal). Subscribe flow surfaces specific errors when the Network
  deploy is missing STRIPE_SECRET_KEY / NETWORK_PRICE_ID /
  NETWORK_USAGE_PRICE_ID so the operator (and the vendor) know
  what's not configured.
- App.tsx: route + new "Billing" nav item in the Network section
  (uses the existing CreditCard lucide icon).

Tests (network-billing-proxy.test.ts, 9 cases): mocked HTTP receiver
acting as the Network's billing endpoints. Covers the happy path
for all three routes, body validation, missing-Network-config 503,
admin auth gate, and Network 4xx/5xx surface as 400/503.

99/99 vendor-side tests pass; portal builds clean (322 KB JS, 93 KB
gzip).

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tnerships, etc.)

Creators no longer have a separate Network portal — their home is the
vendor instance they signed up at. This adds the partner-side surfaces
that used to live at network.openpartner.dev directly into the vendor
portal so a creator on Vendor A can browse and apply to programs at
Vendors B/C/D, see all their cross-network partnerships, and edit a
single Network-wide profile.

API
- partnerProxy: server-side helpers that hit Network's existing
  /offerings + /creators/me/* routes using the vendor's encrypted
  vendorToken plus an x-act-as-vendor-partner header carrying the
  partner's vendor-local id. Network resolves the Creator via
  VendorAffiliation(vendorId, vendorPartnerId), so the same handlers
  serve both browser-creator and proxied-from-vendor cases.
- /api/network/{discover,offerings/:id,vendors/:id} (open to anyone signed in)
- /api/network/me/{affiliations,affiliations/:id/earnings,requests,
  requests/:id/cancel,profile} (partner-role only)

Portal
- pages/partner/{Discover,OfferingDetail,VendorDetail,MyAffiliations,
  MyRequests,MyProfile} matching openpartner UI patterns (Page/Card/
  Stat/StatusPill/Button).
- Sidebar gains a Network section for partners with Discover programs,
  My partnerships, My applications, Network profile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… strip

The multi-tenant build was 90% backend — `/install/status` correctly
returned `{ needsSetup: false, reason: 'multi_tenant' }`, but the
portal SPA had no routes to handle it (everything redirected to
/install) and the backend `tenantMiddleware` resolved tenant context
without stripping `/t/<slug>` from req.url, so downstream routers
mounted at root never matched. End result: app.openpartner.dev/install
loop, no way in.

Backend
- tenantMiddleware now strips /t/<slug> (and /api/t/<slug>) from
  req.url after resolution, so the existing partnersRouter.get('/...')
  registrations match cleanly.
- buildMagicLinkUrl(token, tenantSlug?) — sign-in / invite emails drop
  recipients onto /t/<slug>/auth/magic so the SPA's tenant-aware api
  client scopes the verify call. All 5 callers updated to pass slug.

Portal
- Multi-tenant routing in App.tsx based on install-status.reason: '/'
  → LandingPage, '/signup' → SignupPage, '/t/:slug/*' → Shell.
- api.ts derives tenant slug from window.location.pathname and
  prepends /t/<slug> to every fetch — single-tenant unaffected.
- NavItem uses useTenantBase() so absolute paths like '/links' route
  to '/t/<slug>/links' under multi-tenant. Sign-out + Shell login
  redirect also tenant-scoped.
- Brand: real logo-mark-green.svg in Logo() (was a gradient 'O'
  placeholder); favicons + apple-touch-icon + og-default wired in
  index.html.

Operator: still needs to set OPENPARTNER_TENANCY=multi and
DATABASE_URL_APP on App Platform for the deploy to actually run in
multi mode with RLS enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Landing on app.openpartner.dev now offers two paths from day one:

- Vendors → /signup → create a tenant + admin account (existing)
- Creators → /creator/signup → create a Network Creator account

Creators get their own auth (magic-link via Network), their own Shell
at /creator/* with Discover, My partnerships, My applications, Profile.
A creator's identity is platform-wide; when they apply to a vendor's
program and the vendor approves, the existing federation flow creates
a Partner row in that vendor's tenant linked back to the same Creator.

API
- /api/creator-api/* reverse-proxies a fixed allowlist of Network
  endpoints (creator auth, /creators/me/*, /offerings, /vendors/:id,
  /offerings/:id/apply). Cookies pass through both directions, so the
  Network's `op_network_creator_session` cookie ends up scoped to
  app.openpartner.dev (Network sets it without a Domain, so the
  browser scopes it to whoever responded).
- Mounted before tenantMiddleware so it works at the bare host with
  no /t/<slug>/ prefix.

Portal
- pages/creator/{Discover,OfferingDetail,VendorDetail,MyAffiliations,
  MyRequests,MyProfile,Signup,Signin,MagicLanding,Shell}
- creator-api.ts is a thin fetch helper for /api/creator-api/* with
  cookie credentials and structured ApiError.
- Landing gains side-by-side Vendor + Creator CTA cards plus a
  "Creator sign in" link in the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brands is the term we want for the merchant side; "vendor"/"program"
was internal language leaking into the UI. Three call sites:
Landing CtaCard, Signup AuthFrame title, and the creator-signup
cross-link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Landing now mirrors openpartner.dev's tone — brand+creator parity, no
engineering jargon, the same headline ("Launch a partner program. Grow
it with the Network.") and the same value-prop bullets. Two equal-
weight audience cards driving to /signup and /creator/signup.

Sign-in is unified at /signin: one email field, no "are you a brand or
a creator" picker. Backend looks up Admin rows across all tenants and
the Network Creator by email; emails magic links for whichever match.
Always 200 silently to avoid email enumeration.

- POST /api/signin (multi-tenant only) — admin lookup across tenants +
  Network creator forward, both best-effort.
- /signin SPA page with the standard "check your inbox" success state.
- Landing header: "Sign in" link replaces the creator-only signin
  link; "Already have an account? Sign in" CTA below the audience
  cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modern browsers compile the HTML pattern attribute with the v flag,
which rejects bare - between character class atoms. Both signup forms
were using [a-z0-9-]/[a-zA-Z0-9_-] which the v engine flags as
"Invalid character in character class". Escape the hyphen to keep the
intended literal-dash semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related browser-side cookie bugs in the creator-portal proxy:

1. Node fetch comma-joins multiple Set-Cookie headers when you read
   them via .get('set-cookie'), and the browser then can't parse the
   resulting blob. Use getSetCookie() so each cookie ships as its own
   header.

2. Cloudflare in front of network.openpartner.dev injects __cf_bm
   with Domain=network.openpartner.dev. Relaying that to
   app.openpartner.dev landed both Set-Cookie values mashed together
   in one header, which confused the browser enough that the real
   op_network_creator_session cookie wasn't stored at all (next
   request only carried __cf_bm). Filter Cloudflare cookies out and
   strip Domain= on whatever we do forward so it scopes to
   app.openpartner.dev cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Programs (Network offerings) are configured later from admin → Network
→ Offerings. Signup just provisions the brand's tenant + first admin.
Adjust the labels accordingly:

- Subtitle: "Create your brand account…"
- "Program name" → "Brand name"
- Submit: "Create program" → "Create account"
- Success: "Program created." → "Account created."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the existing stripe_config_probe pattern to cover every secret
the app expects (Stripe full set, Postmark, MAIL_FROM, ADMIN_API_KEY,
SECRETS_ENCRYPTION_KEY, NETWORK_URL, DATABASE_URL_APP, etc). Logs
lengths only, never values.

App Platform silently leaves SECRET envs empty when the cipher-text
blob can't decrypt — typically after a spec-pull-and-reapply where
the EV[...] string was the wrong app's. This probe makes the
"is X actually populated in the runtime container?" question a
single grep instead of a code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The unified /signin filtered to whereNotNull('activatedAt'), which
meant a brand whose signup email never landed (mailer misconfigured,
inbox lost it, etc.) had no self-recovery path — they couldn't sign in
because they weren't activated, and they couldn't re-signup because
the slug was taken.

Drop the activation filter and pick the magic-link purpose by state:
admin_invite (activates on consume) for new admins, admin_signin
(no-op activation, just creates session) for already-activated. Same
endpoint, same UX, signup recovery just works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In multi-tenant mode the brand magic link arrives at
/t/<slug>/auth/magic, the verify succeeds, but MagicLandingPage was
hardcoded to nav('/') after success — and / is the public Landing in
multi-tenant. So brand admins kept landing back on the marketing page
instead of their portal.

Pull the slug from the current pathname and redirect to /t/<slug>/
when present; fall back to / for single-tenant. Single-tenant behavior
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If you already have a brand session (op_session) or a creator session
(op_network_creator_session via the creator-portal proxy), visiting
app.openpartner.dev/ should drop you in your portal — not the public
marketing splash. New /api/session/home endpoint resolves the brand
session through the privileged db (sessions table carries tenantId),
returns { home: '/t/<slug>/' } or null. Public, no auth gate; null on
anonymous.

Landing fires both probes in parallel on mount; first match wins
(brand preferred over creator if somehow both are present). Renders an
empty shell during the check so we never flash the marketing splash
to a signed-in user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-tenant magic-link spray with one identity-level email,
then a picker. New /signin sends a single platform_signin token (no
tenant). Verifying it issues an op_platform_session cookie. The SPA
routes to /workspaces, which lists every Admin row matching the
verified email; user picks one and /api/workspaces/enter exchanges the
platform session for a per-tenant Session and redirects to /t/<slug>/.

If the user only admins one brand, the picker auto-skips. If they
admin many, they get a real picker — multi-brand membership has been
in the data model since the multi-tenant refactor (Admin keyed by
(tenantId, email)), it just had no UX surface until now.

Schema: new PlatformSession table (id, prefix, tokenHash, email,
expires…) — no tenantId because identity isn't tenant-scoped.

Backend
- packages/db: PlatformSession in TABLES, PlatformSessionRow type,
  'platform' added to PrincipalKind + 'platform_signin' to
  MagicLinkPurpose.
- migrations/20260508010000_platform_session.ts.
- apps/api/src/platform-sessions.ts: create/resolve/revoke + cookie
  options.
- apps/api/src/routes/platform-auth.ts: POST /auth/platform-verify,
  GET /me/workspaces, POST /workspaces/enter, POST
  /auth/platform-signout. All mounted before tenantMiddleware.
- routes/signin.ts: emits one platform_signin token instead of N
  per-tenant tokens; falls back to silent no-op if no admins match.
- routes/session-home.ts: returns home: '/workspaces' for platform
  cookies; tenant Session takes precedence if both present.

Portal
- pages/Workspaces.tsx: picker (auto-enters when there's one).
- pages/auth/PlatformMagicLanding.tsx: dedicated landing for the
  platform token (POSTs /api/auth/platform-verify, redirects to
  /workspaces).
- App.tsx multi-tenant routes: /auth/magic now mounts the platform
  landing; /t/<slug>/auth/magic still mounts the existing per-tenant
  one for any direct invite/per-tenant flow.
- New routes: /workspaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browsers only surface past entries when the input has both a name
attribute and an autocomplete hint. Added name="email" /
autoComplete="email" to every email field in the auth surfaces
(Signin, Signup, Login, CreatorSignin, CreatorSignup), name="name"
on the display-name fields, autocomplete="username" on the creator
handle, and autoComplete="off" on the API-key field (we don't want
that one cached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…en inline

Hosted brands signed up for the marketplace platform — the Network is
the value prop, not an opt-in feature. Skip the "Connect to Network"
step entirely: signup auto-enrolls and saves the vendorToken so
membership is `enabled: true` from t=0. Visibility is governed by
whether the brand publishes offerings, not by membership flags.

Wiring:
- network-client.signupWithNetwork accepts adminAuthToken; when set,
  passes it as Bearer to the Network's new admin fast path which
  returns { vendorToken, status: 'active' } inline.
- routes/signup.ts uses NETWORK_ADMIN_API_KEY (env) when present;
  saves membership enabled=true on the active path or enabled=false
  on the legacy email-verify fallback.
- settings.ts manual "Connect to Network" still uses the email-verify
  shape (no admin auth from the SPA).
- secrets_probe extended to surface NETWORK_ADMIN_API_KEY presence.

Operator action: set NETWORK_ADMIN_API_KEY on openpartner App Platform
= openpartner-network's ADMIN_API_KEY value. Without it, hosted signups
fall back to the email-verify flow (still works, just slower).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-phase soft delete for hosted brands:
  - POST /api/account/delete stamps Tenant.pendingDeletionAt + reason,
    revokes every active Session for the tenant. Admin confirms by
    typing the slug.
  - POST /api/account/restore clears it inside the 30-day grace
    window. tenantMiddleware lets the recovery routes through but
    410s every other request for a pending-delete tenant.
  - GET /api/account/deletion-status surfaces hardDeleteAt for the UI.

UI: Settings → Danger zone shows a confirm form when not pending and
the recovery banner when pending.

Creator-side proxy allowlist gains /creators/me/delete + /restore so
the platform-wide creator surface can target the new Network endpoints
through the existing reverse proxy. CreatorMyProfile gets the same
Danger zone UX.

Schema:
- migrations/20260509000000_pending_deletion adds
  Tenant.pendingDeletionAt + Tenant.deletionReason + index.
- types: TenantRow gets the two new nullable fields.

Phase 1 explicitly defers (TODOs in code):
- Stripe subscription cancellation on delete (brand keeps getting
  billed during grace; should cancel-at-period-end at least).
- Network-side vendor row revoke (Network keeps thinking we're
  federated). Needs /vendors/me/delete on the Network.
- Pending payouts / unpaid commissions guard.
- Scheduler sweep for hard delete after 30 days (manual cron until
  then).
- Data export ZIP next to delete (existing /admin/export covers
  brand side; creator-side needs a parallel endpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s + scheduler + export

Brand-side /api/account/delete now does the full cascade:

- Pending-obligations gate: refuses with 409 + a detail body if there
  are unpaid commissions ('pending'/'approved') or in-flight payouts
  ('pending'/'processing'). Admin can override with
  forceDespiteObligations=true after the UI shows the warning.

- Stripe: subscriptions.update(sub, { cancel_at_period_end: true }) —
  brand keeps access for the rest of the current billing period
  (irrelevant in practice since soft-delete locks them out anyway),
  no double charges. Failures logged but don't block the delete.

- Network: networkProxy.deleteVendor calls /vendors/me/delete on the
  Network so the federation hub stops surfacing the brand to creators.
  Membership flag flipped to enabled=false locally.

- Restore mirrors all three: clears cancel_at_period_end, calls
  /vendors/admin-restore (which mints a fresh vendorToken), saves it
  back into network_membership.

network_membership Config gains a vendorId field, persisted at signup
so admin-restore has the id to target after the vendorToken is gone.

Scheduler:
- tenant-hard-delete cron at 04:00 UTC daily. Cascades wipes across
  every tenanted table for tenants whose pendingDeletionAt is older
  than 30 days, then drops the Tenant row.

UI:
- Settings → Danger Zone gains a "Download workspace data" link next
  to Delete (uses the existing /api/export.json).
- Creator profile gains the same "Download my data" next to its
  delete button (uses the new /api/creator-api/creators/me/export
  proxy → Network /creators/me/export).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…coped tokens

Scheduler: each job tick now opens a txn, takes pg_try_advisory_xact_lock
keyed off the job name, runs the handler, commits. If another instance
holds the lock the tick skips. Removes the multi-instance double-fire
foot-gun if we ever scale beyond one App Platform instance.

Partners: /partners/:id/revoke gains grantScope('partners:write') so
the Network's federation revokePartnerOnVendor call (scoped key, not
admin token) actually reaches the handler instead of 401ing. This is
the receiving side of the creator-deletion fan-out — without it the
Network was firing revokes into the void.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Settings: "Program info" → "Brand info", "Program name" → "Brand
  name". Aligns with the marketing voice (a brand has many programs/
  offerings; the workspace is the brand, not a single program).
- Network admin subtitle: "find your program" → "list your brand …
  creators can find and apply to your programs."
- New POST /config/network/auto-enroll: backend uses
  NETWORK_ADMIN_API_KEY to skip the email-verify round trip and mint
  the vendorToken inline, same path the multi-tenant signup uses.
  Requires a session-derived admin (so we have an email to send to
  Network); env/scoped admins fall back to /start-connect.
- Network admin Not-connected card now shows "List my brand on the
  Network" as the primary action. The email-verify form is still
  available behind a "self-host?" toggle for deployments without
  the admin token.

Closes the gap noted earlier: brands created before auto-enroll-on-
signup landed can now self-fix in one click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The partner-invite/signin emails were brand-agnostic — recipients
got "You're invited to the partner program" with no clue who from.
Resolve brand name (program_settings.programName → Tenant.displayName
→ null) and interpolate into subject + body. New shared helper
brand-name.ts so partner-side callers don't duplicate the lookup.

- partnerInviteEmail subject: "You're invited to the partner program"
  → "You're invited to Acme's partner program"
- partnerSigninEmail subject: "Your partner dashboard sign-in link"
  → "Sign in to Acme"
- Both bodies interpolate the brand into the lead sentence.
- Falls back to the original generic copy when brandName is null
  (e.g. self-host without program_settings configured).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
keithfawcett and others added 17 commits August 13, 2026 17:00
…e-pay (#10) (#73)

* fix(payouts): durable transfer intent — stop the direct-Connect double-pay (#10)

runPayouts called stripe.transfers.create INSIDE the caller's tenant
transaction, keyed on a payout id minted in that same transaction. Two
double-pay paths followed:

  1. the transfer succeeds and the COMMIT then fails — the Payout row and
     the paid commissions roll back, the money is gone, and the next run
     regroups under a NEW payout id, hence a NEW idempotency key, hence a
     second transfer;
  2. an ambiguous Stripe error (timeout — the transfer may or may not
     exist) marked the payout failed, left the commissions approved, and
     the next run retried under a new key.

This is a live path: self-host Connect payouts and the deliberate
OPENPARTNER_ALLOW_UNFUNDED_CONNECT_PAYOUTS=1 override. It is not behind
the funding flag.

A deterministic key over the commission set does NOT fix it — if any
commission is approved between attempts the set changes, the key changes,
and the second transfer pays the overlap again.

So mirror funding/executor.ts, which already had the right shape:

- payouts.ts is now a PLANNER. It writes the Payout row as an intent
  (metadata.transferState='intent', frozen amountMinor + destination) and
  freezes its commission set by claiming the rows — Commission.payoutId
  stamped while status stays 'approved'. Every planner lookup filters
  payoutId is null, so claimed commissions can never be regrouped into a
  second, larger transfer. No Stripe call happens inside the transaction.
- payout-transfers.ts is the EXECUTOR. Outside any transaction, on the
  privileged pool: preflight (set unchanged? partner still ready?), CAS
  intent→posted, POST with the durable key payout_<payoutId>, finalize in
  a short transaction, webhooks strictly after the commit.
- Ambiguous outcomes stay 'posted': inside Stripe's ~24h window a retry
  replays the frozen key; past it the intent goes reconcile_required and
  is resolved by paging transfers.list({transfer_group}) for our metadata
  stamp — never a blind re-POST. Proven absent → re-armed as 'intent'.
- Definite 4xx fails the payout and releases the claims so the next run
  regroups. A transfer that comes back reversed is never recorded paid.

Also: the amount now comes from the rows actually claimed rather than a
separately-snapshotted SUM, and withTenantTransaction (tenancy.ts) is the
one way to open a tenant-scoped transaction — the scheduler and the admin
POST /payouts/run both use it, the latter committing its intents before
any money moves instead of borrowing the request transaction.

New scheduler job payout-transfers (*/15) retries and reconciles intents
left open by a crash, a timeout, or an unready partner.

16 DB-backed tests with a Stripe mock cover every path above, including
the commit-fails-after-transfer and set-change scenarios. Operator doc:
docs/direct-connect-payouts.md (state machine, recovery SQL, and the
staging checklist to run in Stripe test mode before this pays anyone).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): adversarial-review fixes — 409 double-pay, stale replay, missing interlock

Codex review of #73 refuted both claims the PR made. Three real defects,
all of them ways the "cannot double-pay" property failed.

1. A 409 IDEMPOTENCY CONFLICT WAS TREATED AS PROOF OF ABSENCE. Every 4xx
   classified as definite → the intent was failed and its commissions
   released. But 409 `idempotency_key_in_use` means another request is
   using this key RIGHT NOW and may well succeed; releasing lets the
   planner regroup those commissions under a NEW key while the first
   transfer lands. That is exactly the double-pay this PR exists to stop.
   429 had the same problem (no transfer, but the intent was destroyed).
   Both are now ambiguous: the intent stays posted and the retry replays
   the frozen key or reconciles by listing.
   The funding executor never released on these; the divergence was mine.

2. TWO WORKERS COULD RE-POST ONE INTENT CONCURRENTLY. A `posted` intent
   past the 60s cooldown fell through to transfers.create with no claim,
   and the admin route, the weekly payouts job and the 15-minute executor
   job hold DIFFERENT locks, so they can overlap. The retry now takes a
   lease by swapping the exact `postedAt` it read — `posted → posted`
   matches for everyone, but only one worker can swap a given timestamp.

3. A RETRIED KEY REPLAYS A STALE OBJECT. Stripe answers a repeated
   idempotency key with the response it stored at creation, so `reversed`
   in that body is false even if the transfer has since been clawed back
   — and finalizing on it overwrote a reversal webhook's `failed` with
   `paid`, marking commissions paid on money that came back. Any attempt
   past the first now re-reads the transfer from Stripe before believing
   it, and a re-read that fails records nothing.

Also: the frozen commission set had no protection from the OTHER side.
`interlockCommissionReversal` only knew about funding allocations, so the
admin reverse endpoint and the refund clawback would flip a commission
claimed by a posted intent — Stripe still gets the frozen amount while
fewer commissions are marked paid. It now holds commissions claimed by an
open Payout intent too, which gives every reversal path the guard for
free. (Preflight only helps before the first POST.)

The PR also claimed money can never be stranded, which was overstated: a
reversed transfer deliberately leaves its commissions claimed with no
automatic path back. That case now has a documented operator disposition
rather than an implied one.

7 new tests: 409 holds the claim, 429 holds the claim, two workers retry
→ one POST, stale replay is re-read and never resurrects a reversal,
interlock holds a claimed commission, and a canceled intent frees it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): split the lease clock from the idempotency-window clock

Self-caught regression in the previous commit. The retry lease swapped
`postedAt` — which is ALSO what Stripe's ~24h key-retention window is
measured from. A steadily-retried intent therefore refreshed its own
window on every attempt, never reached `reconcile_required`, and once
Stripe pruned the key a re-POST would have created a SECOND transfer.
The fix for one double-pay reintroduced another.

Two clocks now, with distinct jobs:
  postedAt — when the frozen key was FIRST used. Never moves. Anchors
             the idempotency window.
  leaseAt  — when the last attempt claimed the intent. Moves on every
             retry; it's the compare-and-swap token that keeps two
             workers off one key, and the cooldown reads it.

Re-arming after a proven-absent reconcile clears both.

Two regression tests: an intent posted 25h ago but retried 2 minutes ago
still reconciles by listing instead of re-POSTing, and the cooldown reads
the lease clock rather than the first post.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-2 review — lease vs expiry race, interlock check/use gap, error type

Second adversarial pass over the fixes themselves. Six findings, all
verified against the code.

1. THE EXPIRY PATH COULD STEAL A WARM LEASE. The window check ran before
   the cooldown and CASed on transferState alone, so a worker that had
   just leased and was inside transfers.create could have its intent
   moved to reconcile_required, listed and finalized by another worker —
   and then its own POST landed on a key Stripe had pruned, creating a
   SECOND transfer. Cooldown now runs FIRST: a warm intent is left alone
   whatever its age.

2. CHECKING THE AGE THEN POSTING IS A TOCTOU AGAINST STRIPE'S RETENTION.
   A worker that measured "23h59m, fine" can have its request arrive
   after the key was pruned. The key is now treated as spent an hour
   early (KEY_SAFETY_MARGIN_MS), so every POST we authorize lands with
   room to spare instead of racing the expiry we just measured.

3. THE REVERSAL INTERLOCK HAD A CHECK/USE GAP. interlockCommissionReversal
   is a read; the status flip is a separate write. The planner can commit
   an intent and claim the commission in between — after which the
   transfer goes out for the frozen amount and the commission is reversed,
   never paid. Both reversal paths (admin route, refund clawback) now
   re-assert the guard INSIDE the UPDATE, so whichever statement lands
   second sees the other's work.

4. THE IDEMPOTENCY-ERROR EXCLUSION CHECKED THE WRONG PROPERTY.
   stripe-node puts the wrapper class on `type` ('StripeIdempotencyError')
   and the API's string on `rawType` ('idempotency_error'). Matching
   `type` against the API string never fired, so a 400-level idempotency
   error (a key reused with different parameters) was still classified
   definite and released the claims.

5. 500 STUCK INTENTS COULD STARVE EVERY OTHER TENANT. The scan is global,
   capped at 500 and ordered by createdAt, so a wedged backlog monopolized
   every pass. Ordered by least-recently-attempted now: each attempt bumps
   leaseAt, so a stuck intent falls to the back on its own.

6. Smaller: a transfer we can see but cannot re-read now persists its
   error and escalates to an ALERT after 5 attempts instead of retrying
   silently forever; an interlock whose Payout row can't be resolved fails
   CLOSED (left join, not inner) since Commission.payoutId has no FK; and
   a hand-repaired `leaseAt: null` takes the IS NULL branch instead of
   `= NULL`, which never matches and would have wedged the intent.

Not applicable from the review: it flagged an upgrade hazard for rows
written by the intermediate commit a38732e. That commit only ever existed
on this unmerged branch — no deployment has ever written a `postedAt`
under those semantics, and `main` has no transferState at all.

6 new tests, including the warm-lease and check/use races.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-3 review — ABA on the expiry CAS, and the key was never fresh

Third adversarial pass. Two of these are the deepest defects found in this
branch so far, and both were introduced by earlier fixes.

1. CRITICAL — ABA ON THE EXPIRY TRANSITION. The posted → reconcile_required
   CAS checked only `transferState`. A worker holding a stale scan
   snapshot could arrive after the intent had been reconciled, re-armed
   and posted afresh by someone else, see `posted` again, and steal that
   NEW generation out from under a live transfers.create. The victim's
   finalize then lost its CAS, so a real transfer went unrecorded — and a
   later POST could pay it again. The transition is now scoped to the
   exact `postedAt` the worker observed, so a stale reader loses.

2. CRITICAL — "A GENUINE FIRST ATTEMPT" WAS NEVER TRUE. After listing
   proved no transfer existed, the re-arm cleared our own clocks and
   re-posted `payout_<payoutId>` — the SAME key. Stripe's retention is
   anchored to when that key was FIRST used, not to our local timestamps,
   so a re-armed POST inside the retention replays the stored outcome
   (including a stored failure) rather than doing anything, and the local
   window restarts — with no ceiling. Proving absence is exactly the
   evidence needed to make a NEW key safe, so the re-arm now bumps a
   `keyGeneration` and posts under `payout_<id>_g<N>`. Generation 0 keeps
   the original key so anything already posted under it still replays.

3. HIGH — THE REFUND PATH SILENTLY DROPPED LATE-HELD COMMISSIONS. The
   guarded UPDATE added in round 2 can refuse a commission claimed after
   the interlock read, but the return still reported only the interlock's
   own held count. Those commissions were reported as neither reversed
   nor held: the refund looked fully handled while a commission for
   refunded revenue stayed payable. They are now counted and alerted.

4. MEDIUM — THE SCAN ORDER MIXED TWO TIMESTAMP FORMATS. `"createdAt"::text`
   renders `2026-08-09 20:04:15.501+00` while the metadata clocks are ISO
   `...T...Z`, and a space sorts before `T` — so every never-attempted row
   jumped ahead of every attempted row sharing a date, regardless of time.
   With a 500-row cap that is queue starvation. All three keys are now
   rendered in the same ISO shape, pinned to UTC.

5. MEDIUM — an unknown `transferState` (rolling deploy, hand-edited row)
   was treated as "not open" and released the reversal hold. The guard now
   keys on the TERMINAL states, so anything unrecognised fails closed.

6. LOW — a failed transfer re-read could write its stale error onto a row
   another worker had already finalized; that write is now predicated on
   still holding the intent.

Not duplicated here: the review also flagged the funding executor's
un-paginated 100-transfer reconcile listing. That is real, and it is
exactly what PR #71 fixes — both need to merge.

5 new tests, including the ABA staged directly against the CAS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-4 review — make keyGeneration a real epoch, not just a key suffix

Round 3 introduced generations to stop a re-armed intent reusing a key
Stripe still retained. It fixed that, and opened a new dimension that
nothing was fenced on. Three critical consequences, all verified:

1. THE RE-ARM WASN'T FENCED. `reconcileIntent` computed `next = read + 1`
   from its own snapshot and CASed on state alone. A reconciler resuming
   from a stale read could therefore hand generation N+1 back out AFTER
   another worker had already posted under it — so a live generation
   became reusable, and once Stripe pruned that key the next POST created
   a second transfer. The re-arm now CASes with the generation it read.

2. GENERATIONS WERE INDISTINGUISHABLE AT STRIPE, AND DUPLICATES WERE
   SILENTLY COLLAPSED. transfer_group stayed the payout id (correct — one
   listing should see every generation) but the metadata carried no
   generation and reconcile took the FIRST match. Stripe lists
   newest-first, so a late transfer from generation N arriving after N+1
   succeeded would have been hidden behind the newer one: two real
   payments, one recorded, nothing detecting it. Transfers now carry
   `openpartner_key_generation`, and reconcile collects ALL matches
   across all pages: zero → fenced re-arm, one → finalize, MORE THAN ONE
   → write no ledger, alert, and leave it for a human.

3. FINALIZATION WASN'T FENCED EITHER. A worker holding a transfer from
   generation N could finalize it against an intent that had since moved
   to N+1 — recording the wrong transfer while N+1's own transfer stayed
   live. Every post-Stripe mutation (finalize, reversed-transfer close,
   definite failure, ambiguous error, retrieve failure) is now scoped to
   the generation it belongs to.

Also:

- A malformed `postedAt` produced NaN, and every comparison with NaN is
  false — so a corrupt timestamp sailed past BOTH safety checks into the
  re-POST path, the one place we must never go on a guess. Unknown age
  now reconciles by listing, which is always safe.
- The refund path attributed every missed row to "a payout intent claimed
  it", so a commission concurrently paid or reversed by another actor
  produced a false alert AND was double-counted against alreadyPaid. The
  missed rows are now re-read and classified.
- The reversal UPDATE guard covered the direct rail but not funding: a
  reservation could insert an allocation after the interlock read, and
  the funded executor would then overwrite `reversed` with `paid`. It now
  excludes live allocations too.

4 new tests, including duplicate detection and a superseded-epoch
finalize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round-5 review — the lease was shorter than Stripe's own request budget

Fifth adversarial pass. Three criticals, all in round-4's epoch wiring,
plus one that has been latent since the lease was introduced.

1. CRITICAL — THE LEASE COULD EXPIRE WHILE THE POST WAS STILL IN FLIGHT.
   `POST_COOLDOWN_MS` was 60s, but our Stripe client passes no options and
   stripe-node defaults to an 80s timeout with 2 network retries — so a
   single transfers.create can occupy ~4 MINUTES. Another worker would
   declare the lease cold, reconcile, prove absence and re-arm, and then
   the original transfer landed alongside the new generation's. Two
   transfers, one recorded, and the row leaves the scanned states so
   duplicate detection never runs on it.
   The money call is now explicitly bounded (20s, 1 retry) and the lease
   (180s) is required to dominate that budget — with a test asserting the
   relationship rather than the constants.

2. CRITICAL — TRANSFERS WERE STAMPED WITH THEIR GENERATION AND THE STAMP
   WAS IGNORED. Reconcile matched on payout id only and finalized with the
   ROW's current generation, so a generation-0 transfer discovered while
   the row was on generation 1 passed the generation-1 fence and was
   recorded as the current epoch's — the exact invariant the stamp was
   added for. It now reads the stamp: a transfer from a superseded epoch
   means an abandoned attempt DID move money, which is the duplicate case
   in slow motion, and it freezes instead of finalizing.

3. CRITICAL — `meta` STAYED THE PRE-CAS SNAPSHOT. Round 4's own conclusion
   was "after a CAS, use the returned row"; I only reassigned `payout`.
   Everything downstream — the idempotency key, the Stripe stamp, every
   fence — kept deriving from the stale object, so a worker could POST
   generation N's key while the database said N+1. `meta` is refreshed
   from every CAS now, and the `intent → posted` transition is fenced on
   the observed epoch too.

4. HIGH — the fence used `("metadata"->>'keyGeneration')::int`, and a cast
   RAISES on a non-numeric value where `coalesce` cannot catch it. One
   malformed metadata blob would make every fenced CAS throw and wedge the
   intent permanently — possibly after money moved. Now a text comparison,
   with the decoded generation validated; an unreadable epoch refuses to
   act and alerts rather than guessing.

5. HIGH — duplicate detection was a permanent hot loop: it left the intent
   in `reconcile_required`, which is scanned every 15 minutes, so Stripe
   was re-listed and the alert repeated forever — and 500 such rows would
   consume the entire scan cap and starve other tenants. Duplicates now
   park in a `duplicate_review` state the executor does not scan, with the
   disposition documented.

6. HIGH — the transfer webhooks ignored the epoch entirely, applying by
   payout id alone: a `transfer.reversed` from a superseded generation
   could mark a legitimately paid payout failed. They now apply only to
   the transfer the payout actually recorded.

7. MEDIUM — the refund path dropped a commission that became `paid`
   between its two reads from BOTH totals, reporting a cleanly-handled
   refund when money had just moved; and it labelled every held row as
   payout-claimed when it could equally be allocation-held.

Codex confirmed generation-0 compatibility holds throughout (absent key
matches every fence), and that `missedIds` is correctly bounded.

4 new tests. The round-4 seam test that gave false confidence here — it
handed finalize the old generation explicitly, which the real reconcile
path never did — is replaced by one that goes through the executor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): run the direct-Connect matrix against real Stripe test mode

The staging checklist in this doc was the stated gate for this PR and had
never been run — neither money path had ever touched Stripe. All six
scenarios now pass: 37 assertions, 0 failures.

Adds scripts/staging-direct-connect.ts so the matrix is a command rather
than a manual checklist. It refuses a live key or a non-local DATABASE_URL,
since it truncates tables and moves money.

Three things only a real run could establish:

- Replaying the frozen idempotency key after a lost response returns the
  SAME transfer. That is the assumption the whole intent design rests on,
  and it was previously only asserted against our own mock.
- Past the 24h window the executor calls transfers.list and never
  transfers.create — verified by counting calls on a wrapped client, not by
  reading the branch.
- An unready destination produces a real Stripe 400, classified definite,
  releasing the claims. The mock's 400 was our own invention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 6 — ambiguity freezes instead of re-arming

Three verified defects, two of them able to pay a partner twice.

1. The safety argument rested on POST_COOLDOWN_MS exceeding the Stripe
   request budget. That was false: stripe-node implements `timeout` as
   req.setTimeout, a socket-INACTIVITY timeout that resets after each
   request stage — its own source says so, contrasting Node with fetch.
   A slow-but-progressing POST has no wall-clock bound and can outlive any
   cooldown, get declared cold, be reconciled, re-armed at a new
   generation, and land as a second transfer.

   No constant fixes this, and neither does a local deadline: aborting the
   await cannot retract a request Stripe already received. So the executor
   no longer re-arms at all. An empty transfers.list means UNKNOWN, never
   ABSENT — the intent is HELD on its generation with commissions frozen,
   later ticks keep listing (a straggler still finalizes normally), and
   only an operator may authorise a fresh key. POST_COOLDOWN_MS survives
   as scheduling and is documented as carrying no correctness weight.

   Adds releaseIntentForRetry and disposeIntent, because a hold with no
   release is a leak. Both are generation-fenced.

2. finalizeTransfer re-read the live transfer only when attempts > 1, so a
   first-attempt transfer reversed between the create response and the DB
   write was recorded paid from a stale body — and the reversal webhook
   could not repair it, because it matched on a stripeTransferId that is
   not stamped until finalization. Now it always re-reads, and the webhook
   matches an unstamped payout on the transfer's immutable metadata
   (openpartner_payout_id + openpartner_key_generation), terminalizing the
   intent so the stale finalizer loses its CAS.

3. markDuplicateReview CASed on state alone, so a reconciler resuming from
   a stale snapshot could quarantine a live newer generation — failing a
   payout whose single transfer had legitimately moved money. Fenced on
   the generation the caller observed.

The Stripe mock had no `retrieve` at all, which is what let defect 2 hide:
it modelled an API whose live object cannot be read. Added, with a seam for
"reversed after the create response".

Both new tests verified to FAIL when their fix is reverted. 298 tests green,
typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): make the matrix prove concurrency and the new hold

Two honest problems with the script as written, both found by the round-6
review of my own work:

- Scenario 6 reported SUCCESS when the planner returned no payout at all,
  so a misconfiguration that never reached Stripe was indistinguishable
  from Stripe definitively rejecting the transfer. That is now a failure.
- The header claimed the script covered round 5's lease-vs-request-budget
  property. It never did — the injected failures happen after the real
  request has fully returned — and that property turned out to be
  unprovable anyway. The header now says what the script does NOT prove.

Adds the two scenarios the rework needs, run against real Stripe:

- 7: two genuinely concurrent executors over one intent → exactly one
  transfer, one recorder, one paid commission. First concurrency coverage
  this matrix has had; every earlier run was single-threaded, which is
  precisely why the CAS and lease went unexercised.
- 8: past the window with nothing at Stripe → three ticks must not post,
  the intent stays reconcile_required on generation 0 with commissions
  frozen, and only releaseIntentForRetry lets it post, exactly once.

50 assertions, 0 failures. Doc updated: the state machine no longer claims
listing proves absence, and the manual-SQL release is replaced by the two
fenced operator calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): delete the cooldown-arithmetic test — it guarded nothing

Both round-6 reviews independently called for this one's removal, and I had
cited it to the user as the guard on POST_COOLDOWN_MS.

It parsed three constants out of the source and asserted
`cooldown > timeout * (retries + 1)`. That is worthless twice over:

- It asserted a guarantee that does not exist. stripe-node implements
  `timeout` as req.setTimeout — a socket-inactivity timeout that resets
  after each request stage — so no arithmetic over those constants bounds
  the wall-clock life of a POST.
- It was not a mutation killer even for the fix it claimed to protect.
  Restoring the old 60s cooldown still passes `60 > 40`, and nothing
  checked the request options ever reached Stripe.

The invariant that does hold is now covered instead: the executor never
re-arms on elapsed time, so a cold lease can never authorise a new key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 7 — four defects, two of them in round 6's own fixes

1. The metadata fallback I added in round 6 had the race it was closing.
   The exact-id match and the fallback are two statements, and the executor
   commits finalization in its own transaction — so it could stamp
   stripeTransferId BETWEEN them: the first missed (id null), the fallback
   missed (it requires id null), and the event fell through to "unmatched"
   and was acknowledged 2xx. Reversed transfer, payout paid, no second
   chance. The transfer case now takes the payout row lock first, so we see
   either the pre- or the post-finalization state and never the gap.

2. disposeIntent accepted duplicate_review and released the claims — a
   straight double-pay with no concurrency needed. A duplicate is disposed
   of by reversing the SURPLUS and keeping one transfer, so those
   commissions have been paid; releasing them let the planner pay them
   again. That state now has its own function, resolveDuplicateReview,
   which takes the operator's actual disposition: a kept transfer records
   the ledger against it and marks the commissions PAID, all-reversed
   returns them to the pool.

3. releaseIntentForRetry could re-arm while a reconciler held a transfer it
   had just found. The generation fence does not cover that — the
   reconciler has written nothing yet, so it is still on the observed
   generation. It now lists the transfer group itself and refuses on
   positive evidence. That is not proof of absence (nothing is), but the
   transfer a reconciler found is by definition visible to a listing.

4. A reversed transfer left confirmed+failed with commissions frozen "for
   operator disposition" and no function accepting that state. disposeIntent
   now takes it, guarded by a new notStatus predicate so a payout whose
   money reached the partner can never be released back into the pool.

Every guard verified to fail its test when reverted. 303 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): pick partner2's payout explicitly — [0] was order-dependent

CI caught what my local run did not. The dispose earlier in the test returns
the first partner's commission to the payable pool, so the following plan()
produces a payout for BOTH partners and their order is not guaranteed.
Indexing [0] happened to pick the right one locally and the wrong one in CI.

Selects by partnerId instead. Ran three times locally to confirm it is
stable rather than lucky.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(payouts): exercise the round-7 listing guard against real Stripe

releaseIntentForRetry now refuses to re-arm when a transfer is visible in
the group. The staging matrix called it without a Stripe client, so that
guard was skipped in the only place it runs for real. Passes the client.

50 assertions, 0 failures against test mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 8 — operator actions must verify their own premise

Round 6 removed "an empty search proves absence" from the automatic paths.
Round 7 then built operator paths that act on an unverified human assertion,
which is strictly weaker than what had just been removed — and a human typo
is silent where a Stripe read is not. This applies that rule.

1. disposeIntent released on `status !== 'paid'`, but finalizeTransfer
   records confirmed+failed for ANY non-zero amount_reversed — including a
   PARTIAL clawback. A $10 reversal on a $50 transfer leaves $40 with the
   partner, and releasing those commissions let the planner pay the full $50
   again: $90 out for $50 owed. It now retrieves the stamped transfer and
   refuses ('money_with_partner') unless it is fully reversed.

2. resolveDuplicateReview validated nothing. `{allReversed}` while transfers
   were live released the commissions and double-paid; a typo'd
   `{keptTransferId}` recorded the payout paid against a transfer that does
   not exist AND wedged the row where no operator function accepted it. Both
   dispositions are now checked against the transfer group: a kept transfer
   must be present and unreversed, and all-reversed is refused while any
   transfer still holds money. My own test had encoded the typo case as
   success — it now asserts the refusal.

3. The listing guard on releaseIntentForRetry only ran `if (stripe)`, and
   the documented operator call in the runbook omitted it — so the guard was
   absent in the one usage operators would copy. The parameter is required
   now. An optional security parameter is one someone forgets.

4. That guard also treated exhausting its 20-page budget as absence. Running
   out of pages is not the same as finding nothing; it returns
   'cannot_verify' instead, as does any failed read.

The staging matrix only ever tested that a re-arm was ACCEPTED, so deleting
the guard produced the same result. It now also proves the refusal against
real Stripe once a transfer exists: 51 assertions, 0 failures.

Runbook updated — every operator call now shows the required client and what
each refusal means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payouts): round 9 — the reversal webhook can now always find the payout

The guarantee the finalizer relies on — "the reversal webhook can always
find this payout" — had two holes, and both are closed by making the
IMMUTABLE identifiers load-bearing instead of the mutable ones:

- duplicate_review was invisible to the webhook: its fallback accepted
  only posted/reconcile_required, so a reversal landing while an operator
  resolved a duplicate was dropped as unmatched — and the stale
  resolution then recorded the reversed transfer as the kept one, paid.
  The webhook now RECORDS reversal activity on a parked review (without
  claiming or terminalizing — siblings may still hold money) and moves a
  duplicateReviewNonce; resolveDuplicateReview fences both commits on the
  nonce it observed, loses to newer activity, and answers review_moved.
  Partial reversals count, and the generation fence deliberately does not
  apply — a duplicate group spans generations by construction.

- metadata.openpartner_payout_id is mutable at Stripe, and every guard
  filtered on it: a cleared-metadata transfer vanished from
  releaseIntentForRetry (re-arm posts a second transfer), from
  {allReversed} (vacuously satisfied by an empty group), and from the
  webhook's identification itself. Membership is now the transfer_group —
  set at creation, immutable, stamped with the payout ULID — everywhere.

Also from round 9's findings, in the functions option B keeps as its
apply-step validation:

- disposeIntent with NO stamped transfer (the common ambiguous case)
  released claims with no Stripe read at all. It now lists the whole
  group and refuses while any member holds money; empty is the operator's
  documented risk decision, not proof.
- A kept transfer must match the intent's FROZEN amount, currency and
  destination — present-and-unreversed alone let a one-cent transfer mark
  a $50 payout fully paid — and every sibling must be fully reversed.
- stripe is REQUIRED in both signatures; the runbook said it was while
  the types said otherwise, and its duplicate_review section told
  operators to release the claims — the exact double-pay
  resolveDuplicateReview exists to prevent. Both corrected.

The nonce fence and the lease guard were checked by reverting them and
watching their tests fail. Full API suite: 316 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payouts): round 9 — a ghost transfer stamp no longer wedges disposal

A confirmed/failed payout whose stamped stripeTransferId answers
resource_missing — a hand repair, or a payout wedged by the pre-round-8
typo path — returned cannot_verify forever: fail-closed with no exit but
raw SQL. A stamp that cannot be dereferenced proves nothing either way,
so disposeIntent now falls back to the same whole-group verification as
the unstamped case: a live member still refuses (the ghost stamp does not
excuse ignoring real money), an empty group opens the operator's
documented risk decision. Any other retrieve error still refuses.

The group pagination is now one shared helper (listTransferGroup) used by
disposeIntent and resolveDuplicateReview, rather than two copies that
could drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payouts): round 10 — authenticate what the group asserts, fence what it cannot

Codex's round-10 pass refuted six of the seven claims put to it. The
fixable set, all verified before fixing:

- The review nonce was the Stripe EVENT id, and Connect events return
  before the event-id dedupe — so a redelivered old event wrote its id
  back and a stale resolution fenced on it committed: an ABA. The nonce
  is a fresh ulid per write now; no observed value can ever return.
- Group membership is unauthenticated: anything on this Stripe account
  can set transfer_group to our payout ULID, and dropping the metadata
  filter (round 9) let reconcileIntent FINALIZE such a member.
  Reconcile now authenticates against the frozen intent — amount,
  currency, destination — plus an immutable time bound: transfer.created
  cannot predate the current generation's postedAt, which also defeats a
  FORGED generation stamp. Failures park for a human, never finalize.
- Identity resolution preferred the mutable metadata stamp over the
  immutable group, so a forged openpartner_payout_id could redirect a
  reversal into unresolved-tenant 2xx. transfer_group wins now.
- A partial reversal (transfer.updated, reversed:false,
  amount_reversed>0) re-asserted `paid` — flipping even a failed payout
  back. Status is never overwritten on a partial; it alerts instead.
- disposeIntent skipped group verification when the stamped transfer was
  fully reversed — a late duplicate holding the full payment was
  invisible. The group is verified ALWAYS.
- listTransferGroup treated an empty page with has_more as a complete
  group; it now refuses (cannot_verify).
- frozenAmountMinor: Number() coercion accepted amountMinor:true as 1
  cent; only an actual finite number counts now.
- NEW transfer.created handler (detector only, no state writes): the
  alarm for the honest prove-absence limit — a POST landing after a
  disposition raises transfer_created_orphan instead of passing silently.
  Register transfer.created on the webhook destination.

Deliberately NOT closed here (documented in the handoff): an unbounded
in-flight POST landing between a verified action's listing and its CAS —
that is the prove-absence limit itself; the detector above is its alarm
and option B's request ledger is its tombstone.

The ABA test replays the SAME event id after a newer event; the
authenticity tests stage a one-cent foreign member and a backdated
forged-generation member. Full API suite: 325 green twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ump, real PKs (#8) (#74)

* fix(export): complete the portability promise — missing tables, SQL dump, real PKs (#8)

CLAUDE.md and the README promise every table exports to CSV + JSON + SQL
with a re-importable round-trip. Three ways it wasn't true:

1. The exported set omitted PartnerProgram, PartnerCommission and Coupon.
   A restored instance had the partner roster but no program grants, no
   record of what any partnership was agreed at, and no coupon →
   (partner, program) mapping — so coupon-driven conversions could not be
   re-attributed. Added, in FK-safe positions.
2. No SQL dump existed at all, though the contract names it.
3. Import hardcoded `id` as the conflict target, but PartnerCommission is
   keyed on partnerId — adding it naively would have thrown on every
   re-import.

Each table now carries its real primary key in EXPORT_TABLES, and the
list doubles as the import order (parents first).

The round-trip test found two more, both live bugs:

- A JS array coming out of `select *` is ambiguous: Program.commissionRule
  is a jsonb array, Program.categories is a text[]. The driver renders a
  bare array as a Postgres array literal, so importing ANY program with
  compound commission rules failed with "invalid input syntax for type
  json". Both paths now read the live column types and render each
  correctly.
- Same ambiguity in the SQL dump: a text[] column got `::jsonb`.

New: GET /export.sql (full bundle) and GET /export/<Table>.sql. The dump
is portable by default — rows are written under a psql variable, so one
file restores anywhere:

    psql "$DATABASE_URL" -v tenant_id=default -f openpartner-export.sql

It opens a transaction, sets app.tenant_id so it works on the RLS-scoped
app role as well as the privileged one, and every statement is ON CONFLICT
DO NOTHING so a restore is idempotent and resumable. `?tenantId=<id>`
bakes a literal instead, for clients that don't run psql meta-commands.

schemaVersion 1 → 2. POST /import still accepts v1 bundles: an export
sitting on someone's disk must not become worthless because we added
tables. SUPPORTED_IMPORT_VERSIONS only ever grows.

Tests (export-roundtrip.test.ts, 11): seed one row in every exportable
table → export → wipe → restore → compare, once through JSON and once
through the SQL dump actually executed against Postgres (including the
portable form put through psql's own substitution). Plus idempotency on
both paths, v1 acceptance, PK/order invariants, and escaping of quotes,
jsonb arrays, text[] and nulls.

Also: the portal export page listed 9 of the 14 tables and offered no
SQL; docs/data-portability.md is the format contract (bundle shape, table
list with keys, restore procedures, and how to add a table).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(export): adversarial-review fixes — dump injection, wrong restore id, string mode

Codex review of #74 found three real defects. All three are in the part
of the change that generates a file someone else later executes, which is
exactly where I should have been most careful.

1. COMMAND INJECTION via `?tenantId=`. The parameter was accepted
   verbatim and interpolated into a `--` header comment. psql
   meta-commands are line-oriented, so a value containing a newline and
   `\! <shell command>` produced a dump that runs that command on the
   machine doing the restore — the documented workflow. The values were
   escaped; the comment was not.
   The id is now VALIDATED, not escaped (`[A-Za-z0-9_-]{1,64}`, refused
   with a 400), header comments strip newlines regardless, and
   buildSqlDump asserts the same rule so no future caller can reintroduce
   it. I wrote exactly this guard for Stripe ids on another branch and
   missed it here.

2. THE DOCUMENTED RESTORE COULD NEVER HAVE WORKED. The fallback was
   `\set tenant_id 'default'` and the docs said `-v tenant_id=default` —
   but `default` is the tenant SLUG. `tenantId` is a foreign key to
   `Tenant.id`, whose seeded value is `01J0000000DEFAULTTENANT0000`, so
   the first row failed the FK and rolled the whole restore back. The
   fallback is now DEFAULT_TENANT_ID, and the docs/README/portal say
   "destination tenant id", not a slug.
   The old test hid this by substituting the real id before executing;
   there is now a test that runs the dump the way `psql -f` with NO -v
   does, using the file's own fallback.

3. The escaping assumed `standard_conforming_strings` is on (the default
   since PG 9.1) but never said so; with it off, a backslash in exported
   data escapes the quote doubling and breaks out of the literal. The
   dump now pins the setting in its preamble.

Also from the same review: normalizeRow revived ANY ISO-shaped string as
a Date, so a partner named "2026-08-09T00:00:00" was corrupted on import
and a lookalike that isn't a valid date could fail it. Now that the
importer reads column types, the coercion is gated on the column actually
being a timestamp.

Tests: no -v restore path, string-mode pin, tenant-id refusal (incl. the
`\!` payload), header-comment containment, backslash/quote round-trip
through the dump, and timestamp-lookalike text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(export): round-2 review — one stale example, two overstated comments

Second review pass found no defect in the four fixes; the runtime
behaviour holds. Three accuracy items:

- The SqlDumpOptions doc block still showed `-v tenant_id=default`, the
  slug that cannot work. Every other copy was corrected in 2f4df82; this
  one was missed.
- Two comments claimed the `?tenantId=` value reaches a psql `\set`. It
  doesn't: literal mode emits no meta-commands at all. The real exposure
  is the header comment in a file psql executes, which is why the value
  is validated rather than escaped — now stated accurately.
- Documented that the portable form needs psql 10+ (`\if`), and that
  `?tenantId=` is the escape hatch for older clients, since an old psql
  would skip the conditional and apply the default over a supplied -v.

Also switched the empty-bundle test off the slug so no test models a
restore command that would fail.

Reviewer confirmed against the source: stripping CR/LF is sufficient for
psql's lexer (only CR and LF end a line — U+0085/U+2028/U+2029 and form
feed stay inside the comment); `SET LOCAL` is correctly placed after
BEGIN and applies to the literals parsed after it; and knex's columnInfo
returns `timestamp with time zone`, which `isTimestampType` matches, so
no timestamp column stopped being revived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(export): round-3 review — 400 on a malformed tenantId, and honest gap docs

Third pass found no defect in the restore mechanics; both remaining items
are contract accuracy.

1. `?tenantId=a&tenantId=b` parses as an ARRAY, and "present but not a
   string" was treated as absent — so a malformed request quietly got a
   portable 200 dump instead of the promised 400. Present-and-not-a-scalar
   is now a 400, like every other malformed value. Covered by the first
   route-level test this endpoint has had; previously either route's
   validation could have been deleted without failing the suite.

2. DOCUMENTED PROMISES THAT THE CODE DOESN'T KEEP. The review found
   `docs/payout-funding.md` §4 claiming the hosted funding sidecars are
   exportable, and `docs/white-label-custom-domains.md` claiming
   `Tenant.whiteLabel` exports losslessly — neither table is in the
   export set. `ARCHITECTURE.md` also still said every table imports with
   `onConflict('id')`, which stopped being true when PartnerCommission
   (keyed on partnerId) was added.
   Rather than bolt six tables on, `docs/data-portability.md` now states
   plainly what is NOT exported and why. `PayoutReversal` is called out as
   the material one: payout status is DERIVED from it, so a restored
   database can hold a `reversed` payout with no ledger behind it.
   Exporting those needs an FK decision first —
   `HostedFundingAuthorization.adminId` references `Admin`, which is
   deliberately never exported — and that is its own PR.

A documented gap is recoverable; a promise the code doesn't keep is what
makes an export untrustworthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(export): round-4 review — make every export claim in the repo true

No runtime defect found; the whole finding was that the contract still
contradicted itself, including in the file I corrected last round.

- `data-portability.md` opened with "every table exports" and then listed
  the exclusions. It now says what it means: everything IN THE BUNDLE
  exports, and here is what isn't in it.
- It also called the bundle a complete "commission/payout ledger" three
  lines after admitting `PayoutReversal` is missing — and a reversal
  ledger is precisely part of the payout ledger. Now scoped honestly: a
  complete record of attribution, commissions and payouts, but NOT of
  payout corrections.
- The never-exported list was incomplete (`Admin`, `SignupBlocklist`,
  `PlatformAuditLog`, `PlatformSession*`) and `WebhookEndpoint` — the
  customer's own outbound config, as opposed to its delivery log — was
  missing from the gap list entirely.
- `payout-funding.md`, `white-label-custom-domains.md` and the
  HostedFunding type comments all still asserted these tables export
  today. They describe a design intent; they now say so and point at the
  gap list.
- The portal promised "Download everything, any time."

Tests: the valid-tenantId assertion checked only a 200 and `BEGIN;`,
which would also pass if the parameter were ignored — it now asserts the
id is actually baked in and no psql variable remains. Added the missing
per-table route case and an assertion on the error body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): escape the backslash in the psql-variable assertion

Lint caught a useless-escape in the assertion added by the previous
commit: '\set' in a TS string is just 'set', so the test was asserting
the dump doesn't contain "set tenant_id" rather than "\set tenant_id".
It passed either way, which is why it slipped through.

My verification chain also let it slip: `pnpm lint | tail && git commit`
takes the pipeline's exit status from `tail`, so a failing lint didn't
stop the commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(export): round-5 review — the last stale export claims, and a real cross-tenant test

No runtime defect again; the review confirmed the table categorisation is
exhaustive (38 in TABLES = 14 exported + 13 never + 11 known gaps) and
that round 4 closed all three holes it had found.

What was left was language I hadn't touched, in the places furthest from
the code I was editing:

- `README.md` still said "export everything" and "every table"
- `ARCHITECTURE.md` said the migration is "the six raw + derived tables"
- `export.ts`'s own header said ANY export re-imports — but CSV is a view,
  not a restore format, which the doc already said
- the hosted-funding and white-label MIGRATION comments, and the
  `TenantRow.whiteLabel` type comment, all describe design intent in the
  present tense

`CLAUDE.md` §2 is deliberately left alone: "every table must be
exportable" is the project's stated commitment, not a claim about today's
implementation. Weakening the principle to match the code would be the
wrong direction — docs/data-portability.md records the delta instead.

Also added the cross-tenant restore test the review kept flagging as
missing. Every existing round-trip exports and imports under the SAME
tenant, so all of them would pass with the tenantId rewrite deleted —
the one behaviour that makes hosted → self-host work. Writing it surfaced
a property worth recording: primary keys are global, not per-tenant, so a
bundle cannot be restored alongside its source in one database
(`onConflict(pk).ignore()` sees the originals). The real scenario is a
different database, which the test now models.

Raised the timeout on the DB-heavy round-trip tests: several were running
within a second of vitest's 5s default, and when one timed out it left
rows behind that failed the next test. Flaky money-adjacent tests are
worse than slow ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(export): scalars are JSON too, and scoping no longer rests on RLS alone

Two round-6 findings, both verified against a live database.

Scalar and null JSON did not round-trip. Both serializers re-serialized a
json/jsonb value only when `typeof v === 'object'`, but pg parses json/jsonb
through JSON.parse — so a column holding '"hello"' hands back the JS string
`hello` and one holding '42' hands back a number. Those were emitted as the
SQL literals 'hello' and 42, which the column rejects, so a perfectly valid
stored value broke the restore outright. Any non-null JSON value is now
re-serialized regardless of its JS type. Confirmed empirically first:
jsonb_typeof reports `string`/`null` for these, and a NOT NULL column
accepts both.

Export scoping was the one path that broke the codebase's own safety net.
`exportTable` was an unfiltered `select *` relying entirely on RLS, but
appDb falls back to the privileged DATABASE_URL when DATABASE_URL_APP is
unset — which db.ts documents as supported precisely because "app-level
tenantId filtering still applies". It did not apply here. exportTable now
takes an explicit tenantId; every exportable table carries one, so the
filter is complete. Prod is unaffected (DATABASE_URL_APP is set there); the
exposure was multi-tenant self-host without the app role.

Also strengthens the v1-bundle test, which called importBundle directly and
supplied no schemaVersion — so it would have stayed green if v1 support were
dropped from the HTTP route, which is where the version gate actually lives.
It now imports through the route and checks an unsupported version is still
refused.

All three fixes verified to fail their tests when reverted (the JSON ones
with "invalid input syntax for type json"). Residual documented: JSON null
and SQL NULL are indistinguishable after `select *`.

270 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): bind an API key to its tenant, not to RLS

Round 7 on the export work found that the tenant FILTER I added does not
fix what I implied it fixed. requireAuth matched a key on prefix + hash with
no tenant predicate and leaned on RLS to keep the lookup inside the tenant.
That holds on the app role — but appDb falls back to the privileged
DATABASE_URL when DATABASE_URL_APP is unset, a configuration db.ts documents
as supported, and there RLS is bypassed. Tenant A's admin key then
authenticated against /t/tenant-b/..., and every tenant-scoped query below
it — including my newly-filtered export — faithfully served B's data.

ApiKey carries tenantId, so the lookup is now bound to the request's tenant
exactly. This is broader than export: it applies to every authenticated
route in that configuration. Prod is unaffected (DATABASE_URL_APP is set).
Verified by reverting: the foreign key authenticates and receives a 200 with
another tenant's full export.

Also corrects two claims of mine:

- docs said JSON-null-vs-SQL-NULL was the ONLY residual. It is not: pg
  parses jsonb through JSON.parse, so 9007199254740993 restores rounded and
  1e400 becomes Infinity, which JSON.stringify writes as null. Silent, unlike
  the null case. Both residuals are now documented and covered by tests that
  assert the CURRENT lossy behaviour, so making the parse lossless later
  fails those tests rather than leaving the doc stale.
- the JSONB-number import test passed with its fix reverted: pg prepares a
  JS number as the text "42", which is already valid JSON input, so the old
  guard was never exercised. Switched to a string, which is prepared bare
  and rejected.

273 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): bind the session cookie to its tenant too — round 7 fixed half

Round 7 bound the API-key path and I called the hole closed. It was not:
resolveSession matched on {prefix, tokenHash} with no tenant predicate and
leaned on the same RLS that the privileged-pool fallback bypasses. So on a
deployment with DATABASE_URL_APP unset, tenant A's admin cookie authenticated
against /t/tenant-b/... and resolvePrincipal handed back an admin principal
for B — the identical hole, in the other credential path.

resolveSession now takes `expectTenantId` as a REQUIRED parameter. Required
rather than optional deliberately: the failure mode here is a security
predicate someone forgets to pass, and an optional one invites exactly that.
`/session/home` passes an explicit null because it is deliberately
tenant-discovering and never grants a principal from the result.

The Admin/Partner revocation lookups are scoped to the session's tenant as
well — on the privileged pool a bare principalId would match another
tenant's row.

resolvePrincipal now throws when req.tenantId is missing rather than
defaulting to null, since null is resolveSession's any-tenant escape hatch.

Verified by reverting: the foreign session authenticates and receives a 200
with another tenant's full export.

273 tests green, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): signout must revoke the token it was given — round 8 broke logout

Round 8 routed signout through resolveSession, which it had just bound to
the request tenant. That silently broke logout in a real flow: `op_session`
is host-wide at path '/', so entering a second workspace overwrites it, and
a signout from a stale tab carries tenant B's token to tenant A's URL. The
tenant-filtered lookup found nothing, the route cleared the browser cookie
and returned 200 — and B's session stayed valid server-side. A client that
kept the token could keep using it.

"Logged out" that leaves a live token is worse than the cross-tenant read
the binding was added to prevent.

Signout now uses revokeSessionByToken: an exact-token revocation that
resolves no principal and applies no tenant predicate. Revoking a token the
caller already holds grants nothing and leaks nothing, so it needs no
binding — it is strictly destruction of the bearer's own credential. It is a
separate function rather than a flag on resolveSession precisely because the
two operations have different safety requirements, and one function serving
both is how the binding got applied where it did not belong.

Verified by reverting: the session survives "logging out".

Unrelated observation while running this: the API suite is mildly flaky
under parallel files sharing one Postgres — two runs failed different files
(export-roundtrip once, compound-rules once) and two consecutive runs then
passed 276/276. Pre-existing, not introduced here, but worth knowing before
trusting a single red run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: handoff brief for the remaining payment-audit work (#10, #12, #8)

Self-contained brief for a fresh agent to pick up the three deferred items:
the direct-Connect transfer double-pay (durable-intent restructure, with the
funding executor as the reference and the deterministic-key shortcut warning),
the rest of the funding-pipeline races, and export portability. Includes the
ground truth (prod facts, manual-payout context, commands, branch conventions)
and the 12 shipped audit PRs + post-merge actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: the three remaining audit items are shipped (#73, #74, #75)

Turns the handoff brief into a status record: what each item was, what
actually shipped, and — the part that is still open — the staging
exercises that have to pass before either money path is trusted.

Item A (#10, PR #73): planner/executor split with a durable payout intent
and a frozen commission set. Item B (#12, PR #75): the three funding
races plus a live-Stripe backstop for missed refund/reversal webhooks.
Item C (#8, PR #74): the three missing tables, per-table primary keys, a
portable SQL dump, and two array round-trip bugs the test found.

No code left on any of the three; the remaining work is the staging
checklists in docs/direct-connect-payouts.md and section H of the funding
staging runbook, plus the two post-merge prod actions for #62 and #63.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: bring the handoff up to date with five rounds of review

The doc still said "all three items are implemented, what's left is
staging" — written before any adversarial review ran. Since then each
branch has gained six review commits, two of the three original
implementations turned out to contain real double-pay paths, and several
of the FIXES did too.

Rewritten around what a fresh agent actually needs:

- the three PRs, what each contains now, and that nothing is merged;
- the pattern, stated up front: every round found defects in the previous
  round's fix, two coverage mechanisms looked like rotation without being
  it, and three tests passed with their own fix reverted. In this code a
  fix that looks like the property is not the property;
- what must happen before merge — #74 after #62 (PartnerProgram has no RLS
  on main and exports rely on it), #73 alongside #71, and the two staging
  matrices that are the actual gate;
- the load-bearing invariants, so they don't get casually broken: the
  epoch fencing, the two clocks, the lease-vs-request-budget relationship,
  the ambiguous-error classification, the commit-after-work cursor, the
  inbox lease answering 409;
- the known gaps as decisions with reasons, not an apology list.

Also records the operational trap that cost me a bad commit: piping lint
into `tail` takes the exit status from `tail`, so a failing lint does not
stop the commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the #71 pairing and record what actually blocks staging

Re-verified the handoff against the branches rather than trusting it.

The merge-order constraint "#73 needs #71 alongside it" was misattributed.
#71 fixes funding/executor.ts — the hosted-funding rail, which is #75's
file, not #73's. #73 never touches it (only funding/interlocks.ts) and its
own reconcile at payout-transfers.ts:609 already paginates. #75 still
carries main's unpaginated limit:100 at funding/executor.ts:458, so #75
without #71 ships the duplicate-transfer hole #75 exists to close.

Verified by extracting the merged blob and reading it — a clean merge-tree
proves no textual conflict, not that the fix survived. It does survive, in
either order. All file-sharing pairs merge clean.

Also records the thing the doc called "the actual gate" but never scoped:
there is no openpartner staging app. Building one isn't required — the
runbook allows `stripe listen`, and local tooling is ready — so the gate is
a Stripe test-mode key plus the runbook fixtures, not infrastructure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: #66 was red, merging is blocked by an unsatisfiable rule, migrations do auto-run

Findings from re-verifying the handoff against the repo rather than reading it.

#66 had been failing typecheck+test since 08-08 behind a real defect: the
negative-age guard was `ageMs < 0`, but Event.ts comes from Stripe's
whole-SECOND `created`, so a same-second conversion truncates to before its
click and was dropped silently. Fixed on the branch with a 5-minute skew
grace plus a test verified to fail when the grace is reverted.

main requires an approving code-owner review that a sole maintainer can
never satisfy — the likeliest reason 16 PRs have sat open. enforce_admins is
off, so --admin works.

Each PR is MERGEABLE against main yet two collide with the batch (#66x#61/#64
on integration.test.ts, #70x#69 on .env.example). Recorded with resolutions.
Full 11-PR combination validated: typecheck + lint clean, 292 tests green.

Corrected two load-bearing facts: prod migrations DO run on api boot (the
entrypoint has done so since April, and the July incidents were code/schema
mismatches, not drift), and the Stripe test key already exists in the
repo-root .env.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the staging gate is no longer hypothetical — both matrices have run

#73's six scenarios: 37 assertions, 0 failures. #75's H1/H5/H6/H7/H8/H9
(+H11 incidentally): 21 assertions, 0 failures. H2, H3, H4, H10 and H12 are
still unrun and the doc says so rather than implying the section is done.

No staging server was needed. "Staging" here means an instance talking to
test-mode Stripe, not a deployed environment, so both ran locally against
Postgres in Docker. Records the reusable test-mode fixtures (platform
account, onboarded and un-onboarded Connect destinations, the ACH customer
+ mandate, and how to top up platform balance) so nobody rebuilds them.

The finding that justifies having run this at all is about Stripe rather
than us: paymentIntents.search is eventually consistent, so a retry firing
inside the indexing window will not find an intent that exists. The frozen
idempotency key is the load-bearing defence inside 24h and search is the
fallback past it — assumed by the design, now confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: round 6 — ten defects fixed, and the assumption round 5 rested on

Round 5's fix assumed POST_COOLDOWN_MS could bound a Stripe request. It
cannot: stripe-node's timeout is socket-inactivity, resetting after each
request stage, so a slow-but-progressing POST has no wall-clock bound. Both
money rails now treat "I did not see it" as UNKNOWN rather than ABSENT —
the transfer executor no longer re-arms on elapsed time, and an empty
paymentIntents.search no longer frees allocations.

That trades liveness for safety, so both rails gained operator disposition.
The doc previously called operator tooling the most valuable follow-up; it
is now a prerequisite, because a hold with no release is a leak.

Records that four sweep-coverage mechanisms have now looked like rotation
without being it, and adds the lesson the round-6 revert checks taught:
three of my OWN tests passed with their fix reverted — a lock test where
blocking was not the property, a coverage test with no churn, and a staging
scenario that passed when it never reached Stripe. Running the suite is not
the check; reverting the fix and watching the test fail is the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: round 7 — twelve more, nine of them in round 6's fixes

Round 6 predicted that its changes were "mostly deletions, so less new
surface to breed a round 7". That was wrong in a checkable direction and the
doc now says so: the deletions produced no findings at all, and every
serious round-7 finding is in something round 6 ADDED — the operator
functions, the webhook fallback, the retry set.

The transferable lesson is that the dangerous change here is the one that
adds a mechanism, and "I removed unsound logic" is not grounds to expect a
clean next round.

Also extends the weak-test checklist with the four shapes round 7 caught:
an early guard short-circuiting before the code under test, a cursor that
wraps and covers everything by accident, a value that is already valid so
the guard is never exercised, and array indexing where order is not
guaranteed. Plus the one from round 7's own fixes — a lock test that asserts
"it blocked" proves nothing, because it will block eventually at some later
write; assert the value the lock protects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: rounds 8 and 9, the ten OPEN findings, and the decision that gates them

Round 8 is fixed and pushed. Round 9 is NOT: ten of its eleven findings are
open, and the Codex output they came from lived in a session scratchpad that
no longer exists — so they are reproduced in full in a new §0 at the top,
which is where a new agent should start.

The most important part is a correction to my own reasoning. I claimed the
verify-then-write gap between Stripe and Postgres meant those operations
could not be made safe, and I acted on that. It is wrong: the target is
CONVERGENCE, not atomicity, and this codebase already works that way — the
finalizer's own comment says the guarantee is that the reversal webhook can
always find the payout. resolveDuplicateReview simply is not wired into that
mechanism: the webhook fallback accepts only 'posted' and
'reconcile_required', so a reversal during duplicate_review is dropped and
the payout is recorded paid with the money gone.

That makes the highest-value outstanding change a one-predicate widening,
not a rewrite.

Also records the open product question that determines the rest — when a
payout freezes, must someone unfreeze it quickly, or can it wait for an
engineer — and Codex's answer that this, not a technical fact, is what
decides between deleting the operator surface and rebuilding it as durable
intents. Plus its round-10 estimates and the line worth carrying: review
count is not the same metric as operational safety. Fewer findings from
deleted code is not safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: round 10 — the decision is B, all ten round-9 findings closed

Keith answered §0.4: fast unfreeze, so B (durable operator-recovery
requests). Round 10 fixed all ten round-9 findings across #73/#75, then a
same-day Codex pass refuted six of seven claims about those fixes; its
thirteen fixable findings are fixed and pushed, and the two that remain
are the prove-absence limit — now with a transfer.created alarm and B's
request-ledger tombstone as the close. §0 rewritten: what happened, the
two findings that remain by design, the ordered next steps (merge first —
the PRs land squashed, so B stacks on main, not on them), and B's full
design ready to execute. #75 now adds a migration; the ground truth and
PR notes say so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: the batch is merged — all sixteen PRs on main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…t §0.4) + round-11 fixes (#76)

* feat(recovery): durable operator-recovery requests — decision B (audit handoff §0.4)

Operators insert an append-only OperatorRecoveryRequest; the existing
scheduler machinery applies it by calling the four operator functions
under their own fences. The request row is the tombstone for the
prove-absence limit: who accepted the risk, when, on what evidence.

- OperatorRecoveryRequest table (RLS'd operational sidecar, listed with
  the export gaps), TABLES entry + row types + app-role grant
- applyRecoveryRequests: skip-locked lease claim (DB clock both sides,
  token-fenced writes), tenant boundary check before anything else,
  outcome mapping (success → applied; definitive → refused; retryable
  trio stays pending, paced at 15m, capped at 10 attempts → failed +
  alert); wired at the top of the payout-transfers job and the funding
  collector tick so a released intent executes on the same tick
- §0.2 prove-absence close: applied direct-Connect requests get a
  24h transfer-group recheck (detector only) that alarms on a live
  transfer the payout no longer expects, even if the transfer.created
  webhook was missed
- Admin API: POST /payouts/:id/recovery, POST /funding/batches/:id/recovery
  (durable insert + one inline apply, synchronous verdict),
  GET /recovery-requests
- transfer.created added to the platform family map (registered on
  Destination A 2026-08-14; was unconstrained)

507 tests green (48 files), typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record §0.3 status — webhook cutover done, B built, auto-approve still unverified

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recovery): round-11 hardening — takeover truthfulness, poison-proof claim, append-only grant, fenced alarms

Codex round-11 refutation pass (4 of 6 findings confirmed, 2 accepted as
outside the trust boundary / by-design with an alert added):

- A claim that dies between its action committing and its settle no
  longer yields a lying 'refused' row: a takeover of an EXPIRED lease
  treats definitive refusals with suspicion, re-reads the target for
  THIS request's operator marker (req_<id> in lastError/failureReason),
  and settles applied:by_interrupted_attempt when found — else refuses
  annotated as ambiguous, with an alert. Claim is now select-then-update
  in one transaction so the old leaseAt (the takeover signal) survives.
- A hand-poisoned attempts counter at int4 max made the claim STATEMENT
  raise on every pass — at the top of the job that moves money. The
  increment is clamped, and claim/recheck failures are caught so the
  rail always runs.
- The app-role grant narrows to SELECT + INSERT: every update runs on
  the privileged pool, so append-only becomes a database property, not
  a convention. ensure-app-role revokes the wider grant on provision.
- Recheck alarms now emit only AFTER the token-fenced write wins, so a
  stale pass that lost its lease mid-listing cannot re-raise a settled
  alarm; recheckOne is side-effect-free.
- Pending requests on an instance with no Stripe client now alert every
  pass instead of holding silently.

510 tests green (3 new), typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…sion B)

The raw function calls stay documented as the break-glass path — the API
is the supported one: durable insert, inline verdict, scheduler retries,
audit tombstone, 24h group re-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… — all clear

H2/H3/H4/H10/H12 (23 assertions, 0 failures, 2026-08-14):
- H2: past the window the retry resolves purely by search-adopt, with
  create FORBIDDEN (test clocks cannot move key retention — documented)
- H3: a create failing WITH an intent stamps the id from the error and
  the retry confirms that PI (fbpc: key), never a second create
- H4: release vs in-flight create as two genuinely interleaved flows —
  orphan canceled, held on empty-is-not-absent, full release on resume
- H10: release halted on search-down holds safely; the resume landed on
  the payment-wins path with the charge id stamped (H11 verified again)
- H12: a REAL refund through the signed webhook route froze the batch
  mid-executor-run; exactly one transfer left, executor stopped

Adds STAGING_SCENARIOS=<subset> filtering. Runbook + handoff doc updated;
only a true multi-process lease race remains unexercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge the applied verdict

The audit marker and the operator-controlled reason land in the same
lastError/failureReason string, so a substring match let request B smuggle
req_<A> inside its reason and settle request A 'applied' on takeover. The
check now matches the per-kind stamped prefix from the START of the
string; everything before the marker is authentication-derived, so the
reason tail cannot reach it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c grant swap, repaired counters, discriminating staging assertions

Codex round-12 refutation pass over everything its round-11 pass never
saw (the round-11 fixes, the marker anchoring, the staging scenarios).
Five findings; four fixed, one accepted + documented:

- requestedBy could embed another request's marker prefix (schema-level
  text, app-role insertable): the operator string now sanitizes / and :
  out of the name segment at stamp time, so the first /req_ in any stamp
  is the stamping request's own delimiter. Tested through the REAL apply
  path — a malicious request genuinely applies, the victim must refuse.
- ensure-app-role's revoke-then-grant now runs in one transaction — a
  boot killed between the statements can no longer strand the app role
  with no privileges.
- Negative poisoned counters (least() kept them negative → cap false for
  ~2^31 claims) are repaired with greatest(...,0)+1 on both counters.
- Accepted: a crash between the recheck's fenced commit and its log line
  loses the LINE, not the finding — recheckOutcome durably records the
  orphan and transfer.created stays the primary alarm; documented in
  place rather than building an alarm outbox.
- All five staging scenarios sharpened so their named regressions fail:
  H2/H3 assert batch PROGRESS, H4 follows through to a decisive landing,
  H10 fails if stuck with a terminal PI after a full window of ticks
  (single-tick probes are invalid — search is not monotonic per request,
  observed live), H12 gains a control batch proving both partners are
  paid un-frozen. Re-run against real Stripe: 29 assertions, 0 failures.

513 unit tests green, typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New harness (staging-two-process-races.ts) spawns genuinely concurrent OS
processes with separate DB connections against real Stripe test mode:

- A: two executors race one FRESH intent end-to-end — exactly one
  transfer at Stripe, payout paid once, commission claimed once
- A2: reconcile+finalize duel over a held intent whose transfer already
  exists — finalized once against the found transfer, never re-posted
- B: 40 inbox events claimed under real contention (19/21 split) — no
  event claimed by both processes, all stamped processed
- C: 24 pending recovery requests split across two apply loops (4/20) —
  every request settled applied with attempts === 1

17 assertions, 0 failures. Also: auto-approve backlog VERIFIED cleared by
a read-only query against openpartner_prod (zero mature-unswept accrued
commissions) — §0.3 item 2 closed with direct evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gitimate-landing acceptance

Codex round-13 pass over the round-12 fixes (5 findings; 4 fixed, 1
accepted with evidence):

- HIGH fixed: request ids are schema-unconstrained, so id = <A's id>+'X'
  produced a stamp of which A's suffix-less marker (rearm / kept /
  all_reversed) was a strict prefix — those kinds now match by EXACT
  equality (their full stamp is known and never truncated); the
  reason-carrying kinds keep the prefix form, whose ':' after the id
  already terminates it. Test drives the full interleaving via the real
  apply paths.
- MEDIUM accepted: pre-sanitizer stamps would mismatch — but prod has
  ZERO recovery-request rows ever (verified), and the mismatch lands on
  the safe annotated-refusal branch. Documented; no legacy fallback on
  purpose (it would re-open the round-12 forgery).
- 3 LOW fixed in the staging script: H2 accepts funding_failed when the
  adopted PI is already canceled (release protocol owns it); H3 accepts
  a real confirm rejection with the attempt recorded; H4/H10 replace
  timing-sensitive checks with a DETERMINISTICALLY WARM search probe — a
  client whose search always returns the PI — so a live resume must act
  on that tick, separating cold search from a dead resume without racing
  Stripe's non-monotonic index.

Staging re-run (H2/H3/H4/H10): 20 assertions, 0 failures. 514 unit tests
green, typecheck + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs, worker-mode guards, rendezvous barrier

Codex round-14 pass over the two unreviewed commits (the two-process
harness + the round-13 fixes). Ten findings; nine fixed, one documented:

- HIGH: non-ULID request ids are refused at the door (invalid_request:id)
  — closes the whole id-shaped marker-forgery class (+X extension, :B
  colon injection, anything varchar(32) admits); exact/prefix matching
  stays as defense-in-depth.
- HIGH: a takeover-ANNOTATED refusal now schedules the §0.2 group
  recheck exactly like an applied request — a successful rearm's marker
  is legitimately cleared when the executor finalizes, so the takeover
  cannot tell 'never acted' from 'acted and completed', and the
  superseded generation's in-flight POST is precisely the late-lander
  the recheck exists for. Ambiguity earns the backstop.
- HIGH: the harness guards (test key, local DB) now run in WORKER mode
  too — RACE_WORKER=intent against a prod URL refused nothing before.
- MEDIUM: warmSearchStripe honors the query (blind stubs mask
  query-scoping regressions); H3 retrieves the PI to distinguish a
  genuine confirm rejection from confirm-ok-but-CAS-failed; reset()
  deletes Identity before Click (non-cascading FK made a half-wipe
  possible).
- LOW: workers rendezvous on ready-files (cold-start skew cannot hand
  one process the whole workload); Race A accepts the safely-posted
  slow-Stripe landing; balance-aware top-up + scratch-file cleanup.
- Documented: the harness's honest scope — money/claim exactly-once
  under real concurrency; internal fence discrimination is owned by the
  unit suites with staged seams.

Re-runs: two-process 17/17, H3/H4/H10 15/15, unit suite 515 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uery probes, deterministic Race A completion

Codex round-15 pass on the round-14 fix commit (3 findings, all fixed):

- The tenant-boundary check (and its ALERT) now runs before the id-shape
  gate: a hand-insert that was both malformed AND cross-tenant used to
  settle quietly as invalid_request:id, suppressing the loud
  tenant_mismatch alert. Boundary problems refuse loudly; shape problems
  refuse quietly. The id gate still precedes every operator-function
  call and marker use.
- warmSearchStripe matches the EXACT production query (metadata key +
  batch id, the shape findFundingPaymentIntent builds) — a substring
  check still certified a wrong-metadata-key regression.
- Race A no longer accepts the safely-posted landing as terminal: it
  completes the intent through the real finalizer with the listed
  transfer and holds the full paid-once assertions either way, so a
  broken live finalization can no longer false-pass.

Re-runs: two-process 17/17, H4/H10 7/7, unit suite 516 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* security: pin patched proxy-addr

* security: update vulnerable API dependencies

* chore: refresh upstream security lockfile in CI

* chore: refresh lockfile for security updates

* chore: remove temporary lockfile refresh workflow

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(auth): avoid /t/default links in single-tenant mode

* test(auth): cover single-tenant portal URLs

---------

Co-authored-by: Keith <keith@brightyard.co>
Co-authored-by: Keith <keith@brightyard.co>
Co-authored-by: Keith <keith@brightyard.co>
Co-authored-by: Keith <keith@brightyard.co>
Comment thread pnpm-lock.yaml
@@ -2388,12 +2830,16 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}

tinypool@0.8.4:
resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==}
tinypool@1.1.1:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical severity vulnerability introduced by a package you're using:
Line 2833 lists a dependency (tinypool) with a known Critical severity vulnerability. Fixing requires upgrading or replacing the dependency.

ℹ️ Why this matters

Affected versions of tinypool are vulnerable to Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution'). Tinypool builds worker options by spreading them into a plain object, so inherited properties from a polluted Object.prototype (such as execArgv or env.NODE_OPTIONS) are passed to every spawned worker. An attacker who can pollute Object.prototype anywhere in the process can gain arbitrary code execution in all pool workers, including those created internally by Vitest.

References: https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-91718, GHSA, CVE

To resolve this comment:
Upgrade this dependency to at least version 2.1.1 at pnpm-lock.yaml.

💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

You can view more details on this finding in the Semgrep AppSec Platform here.

TylonHH and others added 2 commits October 13, 2026 00:25
* fix: apply tenant brand colors to transactional emails

* fix(mail): never brand platform-voice email

The workspace sign-in (/signin) runs under whichever Admin row matched
first, so it was painted in an arbitrary brand's color; ops alerts and the
ops-console sign-in went out in the default tenant's color. A Message can
now be marked `platform`, which skips tenant branding, and those three
sends set it. The marker rewrite is also anchored to our own <table>/<a>
tags, so text that merely contains `data-op-email=` (an escaped partner
name) is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Keith <keith@brightyard.co>
Comment thread apps/api/src/mailer.ts Fixed
Comment thread apps/api/src/mailer.ts Fixed
Comment thread apps/api/src/mailer.ts Fixed
TylonHH and others added 8 commits October 13, 2026 00:53
* feat(portal): let brands hide Network pages for local partners

* fix(settings): partner Network toggle is self-host only

On the hosted multi-tenant platform, partner Network discovery is part of
the product (hiding it is what the paid white-label add-on does), so the
setting is forced on there: reads report true whatever is stored, saves
are ignored, and the API reports partnerNetworkToggleAvailable=false so the
admin checkbox is hidden. Self-host keeps the toggle. The checkbox is also
hidden for white-label tenants, and the unused PublicBranding field from
the public /branding bootstrap is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ired codes stop attributing (#97)

P1 (security, hosted): provisionCustomerReward created a Stripe Coupon +
PromotionCode with the deployment's STRIPE_SECRET_KEY and no stripeAccount.
On hosted deployments (flat/revshare) that key is OpenPartner's own billing
account, whose plan Checkout runs with allow_promotion_codes — so any brand
admin could set a 100% / free_months customerReward, mint a coupon, and use
the code on OpenPartner's own plans. It also never worked for hosted brands
(the code didn't exist in their Stripe) and shared one code namespace
across tenants.

- rewardStripe(): rewards are only provisioned/deactivated when the deploy
  is single-tenant self-host (the only shape where the key is the brand's).
  There is no per-tenant brand Stripe connection on Tenant today, so hosted
  and multi-tenant self-host skip Stripe and leave the ids null.
- codeExistsInStripe(): a partner-chosen or generated code may not shadow
  an existing promotion code (active or not) or coupon id in the brand's
  Stripe — redemption matches on the string, so claiming WELCOME10 would
  credit the partner with every order on the brand's sitewide promo.
  Admin-chosen codes may still map an existing promo deliberately. The
  POST route fails closed (503) when Stripe can't answer; auto-mint fails
  open since its codes are random draws.
- A promo whose Coupon row then fails to insert is deactivated (POST
  code_taken / any insert error). Auto-mint used ON CONFLICT DO NOTHING,
  so its 23505 retry never fired and a colliding draw stranded its promo;
  it now detects the collision from RETURNING, releases that draw's promo
  and re-rolls with its own.

P2: POST /coupons/redeem and findCouponByCode (Stripe webhook
auto-redemption) now filter deactivatedAt IS NULL, so retired codes stop
attributing new redemptions as the comments already claimed. Users a code
already stitched keep attributing via their Identity.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coupons): honor customization for local partner sessions

* fix(coupons): never mint customer rewards in the platform Stripe; retired codes stop attributing

P1 (security, hosted): provisionCustomerReward created a Stripe Coupon +
PromotionCode with the deployment's STRIPE_SECRET_KEY and no stripeAccount.
On hosted deployments (flat/revshare) that key is OpenPartner's own billing
account, whose plan Checkout runs with allow_promotion_codes — so any brand
admin could set a 100% / free_months customerReward, mint a coupon, and use
the code on OpenPartner's own plans. It also never worked for hosted brands
(the code didn't exist in their Stripe) and shared one code namespace
across tenants.

- rewardStripe(): rewards are only provisioned/deactivated when the deploy
  is single-tenant self-host (the only shape where the key is the brand's).
  There is no per-tenant brand Stripe connection on Tenant today, so hosted
  and multi-tenant self-host skip Stripe and leave the ids null.
- codeExistsInStripe(): a partner-chosen or generated code may not shadow
  an existing promotion code (active or not) or coupon id in the brand's
  Stripe — redemption matches on the string, so claiming WELCOME10 would
  credit the partner with every order on the brand's sitewide promo.
  Admin-chosen codes may still map an existing promo deliberately. The
  POST route fails closed (503) when Stripe can't answer; auto-mint fails
  open since its codes are random draws.
- A promo whose Coupon row then fails to insert is deactivated (POST
  code_taken / any insert error). Auto-mint used ON CONFLICT DO NOTHING,
  so its 23505 retry never fired and a colliding draw stranded its promo;
  it now detects the collision from RETURNING, releases that draw's promo
  and re-rolls with its own.

P2: POST /coupons/redeem and findCouponByCode (Stripe webhook
auto-redemption) now filter deactivatedAt IS NULL, so retired codes stop
attributing new redemptions as the comments already claimed. Users a code
already stitched keep attributing via their Identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(coupons): harden partner self-service minting

Builds on #97 (merged into this branch), which stops partner codes from
shadowing a brand's existing Stripe promos and releases orphaned promos.

- Rolling cap: 10 self-service codes per partner per program per 24h,
  counting deactivated ones. The active-codes cap alone let a partner
  create/deactivate in a loop, minting fresh Stripe objects each time.
- The brand-wide verification gate now runs after the access checks, so a
  caller who can't mint here never learns the brand's coupon count, and
  partners get a partner-facing message instead of integration steps.
- Admin hint covers local partners and warns that codes match by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coupons): honor customization for local partner sessions

* Show customer discount terms when partners choose coupon programs

* fix(coupons): never mint customer rewards in the platform Stripe; retired codes stop attributing

P1 (security, hosted): provisionCustomerReward created a Stripe Coupon +
PromotionCode with the deployment's STRIPE_SECRET_KEY and no stripeAccount.
On hosted deployments (flat/revshare) that key is OpenPartner's own billing
account, whose plan Checkout runs with allow_promotion_codes — so any brand
admin could set a 100% / free_months customerReward, mint a coupon, and use
the code on OpenPartner's own plans. It also never worked for hosted brands
(the code didn't exist in their Stripe) and shared one code namespace
across tenants.

- rewardStripe(): rewards are only provisioned/deactivated when the deploy
  is single-tenant self-host (the only shape where the key is the brand's).
  There is no per-tenant brand Stripe connection on Tenant today, so hosted
  and multi-tenant self-host skip Stripe and leave the ids null.
- codeExistsInStripe(): a partner-chosen or generated code may not shadow
  an existing promotion code (active or not) or coupon id in the brand's
  Stripe — redemption matches on the string, so claiming WELCOME10 would
  credit the partner with every order on the brand's sitewide promo.
  Admin-chosen codes may still map an existing promo deliberately. The
  POST route fails closed (503) when Stripe can't answer; auto-mint fails
  open since its codes are random draws.
- A promo whose Coupon row then fails to insert is deactivated (POST
  code_taken / any insert error). Auto-mint used ON CONFLICT DO NOTHING,
  so its 23505 retry never fired and a colliding draw stranded its promo;
  it now detects the collision from RETURNING, releases that draw's promo
  and re-rolls with its own.

P2: POST /coupons/redeem and findCouponByCode (Stripe webhook
auto-redemption) now filter deactivatedAt IS NULL, so retired codes stop
attributing new redemptions as the comments already claimed. Users a code
already stitched keep attributing via their Identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(coupons): harden partner self-service minting

Builds on #97 (merged into this branch), which stops partner codes from
shadowing a brand's existing Stripe promos and releases orphaned promos.

- Rolling cap: 10 self-service codes per partner per program per 24h,
  counting deactivated ones. The active-codes cap alone let a partner
  create/deactivate in a loop, minting fresh Stripe objects each time.
- The brand-wide verification gate now runs after the access checks, so a
  caller who can't mint here never learns the brand's coupon count, and
  partners get a partner-facing message instead of integration steps.
- Admin hint covers local partners and warns that codes match by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(portal): partner coupon page only states facts about Stripe

The 'discount terms' note now shows only in the partner's own view, and the
per-code Stripe line appears only when a promotion code really exists
('applies automatically at checkout'). Hosted rewards never live in our
Stripe (#97), so 'Stripe creation not recorded' would have shown on every
hosted code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Keith Fawcett <keith@brightyard.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…n single-tenant self-host (#100)

The 409 verification_required detail on POST /partners/:id/coupons told
every brand it could unlock further mints by pointing a Stripe webhook at
/webhooks/stripe. On hosted (flat/revshare) and multi-tenant deployments
that path never sees the brand's own checkout or invoice events: the
webhook verifies only OpenPartner's signing secrets and resolves tenants
from platform or connected-account objects. POST /coupons/redeem is the
only coupon attribution path there.

The detail now comes from couponVerificationDetail(), which mentions the
webhook only when getMode() is selfhost and getTenancyMode() is single.
Portal consumers key off the verification_required error code and render
detail verbatim, so nothing else changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… it (#98)

The Programs editor promised every brand that a customer reward is
"auto-provisioned as a Stripe coupon … discount applies automatically".
Only a single-tenant self-host can do that — its STRIPE_SECRET_KEY is
the brand's own. On hosted (flat / revshare) and multi-tenant
deployments the key is OpenPartner's, so the brand has to create the
matching promotion code in its own Stripe.

- API: customerRewardDelivery() ('stripe' | 'unconfigured' | 'brand'),
  returned with GET /programs so the editor needs no extra request.
- Portal: the reward hint, the free-months note and the vanity-code
  note are worded per value. Hosted copy says to create a promotion code
  with the same string in your own Stripe (or apply the discount in your
  checkout) and that OpenPartner attributes it via POST /coupons/redeem.
  Unknown (older API / loading) promises nothing either way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Saving a program syncs it to the Network with published=true, and since
the May campaign/offering collapse that path never went through the
approval gate (which only fenced the old POST /admin/network/offerings).
Hosted brands are minted as active Network vendors at signup, so a brand
still waiting on operator review was listed the moment it saved a program.

Visibility is now decided where it lives — on the Network, via the new
Vendor.reviewStatus (openpartner-network fix/vendor-review-gate):
- Enrollment (signup, add-brand, auto-enroll) sends the tenant's
  approvalStatus; approve pushes 'approved', reject pushes 'rejected' (and
  still suspends). Reactivate is only called when reinstating a rejection.
- network-review-reconcile job (every 15 min, hosted multi-tenant only)
  mirrors approvalStatus onto every federated vendor. It retries failed
  pushes and is the backfill that hides already-pending brands after the
  Network migration defaults existing vendors to 'approved'.

Also:
- Approval gate matched req.path exactly while Express routes are
  case-insensitive and ignore trailing slashes — POST /Partners/ bypassed
  it. Paths are canonicalized now; invite-creator and the re-federation
  routes (start/complete-connect, /config/network with a token or URL) are
  gated so a pending brand can't mint a second, un-reviewed vendor.
- A blocked program no longer comes back on the marketplace when the brand
  re-saves it.
- Pending banner says the marketplace listing stays hidden until approval.

Deploy order: Network first (migration), then this; the reconcile job's
first run does the backfill.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rnings (#103)

* feat(notifications): setup reminders for brands + creators, policy warnings

Most brands and creators sign up and stall before they can earn anything:
41 of 43 creators are hidden from discovery with incomplete profiles, and
brands stop before creating a program, listing it, or connecting tracking.

Brand setup reminders (openpartner):
- brand-setup.ts: one checklist (program, marketplace listing, conversion
  tracking, first partner) from local signals, batched across brands. Feeds
  the reminders, the ops console, and the dashboard's new tracking step.
- brand-setup-reminders.ts: up to 3 emails (2 days after signup, then 4 and
  7 days later) to the brand's signed-in admin, in the platform voice.
  Exactly-once via a BrandNotification claim row; failed sends release it.
- Signed one-click unsubscribe (GET confirm page, POST + RFC 8058
  List-Unsubscribe-Post), scoped so it never silences sign-in or billing.

Creator profile reminders: the Network sends them (it owns creators and
the unsubscribe flow, see openpartner-network feat/creator-profile-reminders);
openpartner's scheduler triggers the run and the console previews it.

Both sweeps are OFF until an operator turns them on in the new ops-console
Notifications tab (PlatformSetting table: runtime config in the DB, not
env), which previews exactly who the next run would email and can send now.
Brands and Creators cards show setup/reminder history and a manual "send
reminder" button.

Marketplace policy warning (manual, per Keith): Brands card action that
emails the brand a templated notice (the free listing means creators must
join and be tracked through OpenPartner; fix by the deadline or the listing
is removed) with the operator's findings, and records it in the audit log.

Creator portal: a checklist on the profile page of exactly what's missing
(now including the handle the directory already required), a site-wide
"brands can't find you yet" banner, and expectations set at signup.
Dashboard: fixes the duplicated "Create your first program" step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(notifications): pass NETWORK_URL so creator unsubscribe links keep the /api prefix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(notifications): end-of-day policy deadlines; creator Getting started card matches the discovery bar

The deadline was now+N days, so the email (UTC) and the console (local
time) could show different dates. The Discover card listed handle + bio +
apply, out of step with the real requirements; it now points at the same
checklist as the profile page and banner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread apps/api/src/mailer.ts
to: msg.to,
replyTo,
subject: msg.subject,
text: msg.text,
Comment thread apps/api/src/mailer.ts
replyTo,
subject: msg.subject,
text: msg.text,
html,
Comment thread apps/api/src/mailer.ts
replyTo,
subject: msg.subject,
text: msg.text,
html,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants