Repository navigation
Re-review: admin personas + install wizard + mail UI + Network carve - #9
keithfawcett wants to merge 274 commits into
Conversation
…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>
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>
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>
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>
…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>
| @@ -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: | |||
There was a problem hiding this comment.
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.
* 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>
* 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>
| to: msg.to, | ||
| replyTo, | ||
| subject: msg.subject, | ||
| text: msg.text, |
| replyTo, | ||
| subject: msg.subject, | ||
| text: msg.text, | ||
| html, |
| replyTo, | ||
| subject: msg.subject, | ||
| text: msg.text, | ||
| html, |
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`:
Plus three small CI fixes (`63f016d`, `ba67b70`, `a6db3a6`).
Areas of highest concern for review
Not for merge.