Re-review: admin personas + install wizard + mail UI + Network carve#9
Re-review: admin personas + install wizard + mail UI + Network carve#9keithfawcett wants to merge 230 commits into
Conversation
Downstream packages import from @openpartner/db via its package.json "types": "dist/index.d.ts" field. Locally this is fine because dev work rebuilds the db package on every types change, but CI starts clean and typecheck fails with "Cannot find module '@openpartner/db'" in apps/router/src/server.ts. Add a build step for the db package right after install, before Migrate / Lint / Typecheck / Test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WHATWG URL.hostname on IPv6 returns "[::1]" with the brackets intact, which made isIP() return 0 and the guard fall through to DNS lookup (→ ENOTFOUND in the test suite, and a missed loopback rejection in production). Strip [] before the IP check. Also echo the err.message + stack to stderr in the 500 handler so CI logs surface test-harness swallowed errors — a vendor signup test that 500s in CI but not locally was opaque without this.
YAML parsed the unquoted 64-zero string as the integer 0, the runner exported NETWORK_ENCRYPTION_KEY=\"0\" (one char), and every code path through encryptKey/decryptKey 500'd on \"must decode to exactly 32 bytes\". Caught via the error-logging step added in the prior commit. Quoting the value (and the similarly-shaped ADMIN_API_KEY for good measure) keeps the string intact.
OpenPartner OSS is now strictly vendor-direct: a company runs this
codebase to manage their own partner program and nothing in here
knows about a creator network, a two-sided marketplace, or anyone
else's instance. The creator-discovery / matchmaking layer lives
in a separate private repo (staged at
../openpartner-network-seed/) and integrates with OSS instances
over the existing scoped-API-key federation contract — no shared
schema, no inbound coupling.
Removed:
- apps/api/src/routes/network-*.ts (vendors, creators, offerings,
requests, earnings — 5 files)
- apps/api/src/routes/magic-link.ts (creator + vendor signup /
signin branches)
- apps/api/src/network/ (federation client, crypto envelope,
validation schemas, SSRF-safe fetch)
- apps/api/src/auth-sessions.ts, mailer.ts, email-templates.ts
(only used by the removed magic-link flow)
- packages/db migrations: network / promo_codes / magic_links /
vendor_email (four files, pre-1.0 — no shipped consumers)
- NetworkVendor, NetworkCreator, Offering, PartnershipRequest,
Partnership, MagicLinkToken, Session, DevMessage tables (via
new drop_network_tables migration — drop-if-exists so fresh
installs and pre-carve installs both land in the same state)
- apps/portal: network/ pages (10), auth/Signup, auth/VendorSignup,
auth/MagicLanding, admin/DevMailbox
- Associated tests: network.test.ts, magic-link.test.ts,
mailer.test.ts, safe-fetch.test.ts (30 tests dropped; the
surviving suite covers attribution, payouts, webhooks,
observability, rate limiting, export, fraud review, and the
ultrareview regression set)
- .env.example / .do/app.yaml / docker-compose.prod.yml /
ci.yml: dropped NETWORK_ENCRYPTION_KEY, POSTMARK_*, MAIL_*
- docs/email.md
Kept + simplified:
- Scoped API keys (the federation contract — an external
Network calls POST /partners + POST /links on this instance
as a scoped-keys client)
- Admin + partner role model (no more network_vendor /
network_creator principals)
- Stripe Connect payouts, webhooks, attribution engine, router,
SDK — all intact
Portal Login simplified to API-key only. README + ARCHITECTURE.md
reposition the project as "run your own partner program" with a
small note about the external Network service.
knexfile.ts opts into disableMigrationsListValidation so knex
doesn't refuse to boot when it sees the deleted migrations' rows
in knex_migrations on pre-existing DBs.
Test count: 58 total across workspaces (44 api + 3 router + 11 sdk),
down from 88 due to the 30 removed tests. Lint + typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admin no longer issues (or sees) partner credentials. The flow is now:
1. Admin clicks "Invite partner" — enters email + name
2. OSS creates a Partner with activatedAt=null and emails them a
one-time magic link via POST /auth/signin or POST /partners
3. Partner clicks the link → /auth/magic in the portal → POST
/auth/magic/verify → op_session cookie + activatedAt stamped
4. Returning partners enter their email on the Login page's Email
tab → another magic link → back in
Sessions are cookie-backed (prefix+hash like ApiKey; revokable;
30-day TTL). Session cookie is honored alongside Bearer on every
request so the portal works with either auth mode.
New:
- MagicLinkToken + Session + DevMessage tables (narrow vs. the
earlier Network-era schema — email + partnerId + purpose only)
- Partner.activatedAt column (null = invited, backfilled to
createdAt for existing rows)
- routes/partner-auth.ts: /auth/signin, /auth/magic/verify,
/auth/signout, /dev/mailbox
- POST /partners sends the invite by default (sendInvite=false
for federation / seeding paths that don't want mail)
- POST /partners/:id/invite to resend
- mailer.ts (Postmark + DevMailer fallback), email-templates.ts
- auth-sessions.ts (issueMagicLink, consumeMagicLink,
createSession, resolveSession, revokeSession)
- Portal Login.tsx: Email tab back, MagicLanding.tsx handles the
?token=... click, AdminPartners renames "Create" → "Invite"
and adds resend-invite + status pill
5 new tests for the invite flow (invite-then-verify, single-use
tokens, returning-partner signin, user-enumeration-safe signin,
resend). 49 api / 3 router / 11 sdk, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the product decision to rely on Postmark in every environment, the mailer now selects transport implicitly from env: POSTMARK_SERVER_TOKEN + MAIL_FROM set → Postmark otherwise → console.log (dev convenience) No more MAIL_TRANSPORT switch, no DevMessage table, no /dev/mailbox endpoint, no "check your inbox in the admin UI" flow. Tests inject a capturing mailer directly via __setMailerForTests; local devs without Postmark creds see the magic link in the dev:api console. Also drops DevMessage from the still-unshipped partner_auth migration and adds a tear-down migration for any instance that already applied the earlier version of it. 49 api / 3 router / 11 sdk tests all green after the rewrite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…links
Two admin affordances that undercut the invite-first / partner-owns-their-
account model get removed from the UI:
- "Issue key" action on Partners — admin no longer sees partner API
keys. If a partner needs programmatic access, they mint it from
their own dashboard (future Settings page).
- "New link" on /links when viewing as admin — the page becomes
read-only for admins, with copy that says partners manage their
own. Partner role still sees the Create button and the full flow.
Backend routes for /partners/:id/api-keys and POST /partners/:id/links
remain intact (the scoped-key federation path uses them via grantScope)
but the admin UI no longer surfaces either.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admin can suspend a partner and later reinstate. Everything is reversible
and history-preserving:
- Partner.revokedAt column flipped by POST /partners/:id/revoke (admin).
Same transaction revokes every open Session for that partner so the
portal kicks them out of any tab mid-request.
- POST /partners/:id/reinstate clears it. Historical commissions are
never touched; admin reverses specific fraudulent ones separately
via the Review Queue.
- Attribution engine filters out clicks tied to revoked partners
before evaluating the window, so new events skip them cleanly.
- Router still 302s /r/<linkKey> for a revoked partner's links — no
broken UX from a pulled partner — but stamps fraudFlag='revoked'
so the click is visible in audit but never attributed.
- resolveSession rejects sessions whose partner has been revoked, as
defense-in-depth against a race where a revoke tx lands mid-flight.
Admin UI: Revoke button (confirm-modal, red) on active rows; Reinstate
on revoked rows. StatusPill renders "revoked" in danger coloring.
6 partner-invite tests now (+1 for revoke happy path + reinstate).
50 api / 3 router / 11 sdk, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Revocation now matches the industry norm — notify by default, admin
can silence for fraud cases, optional reason threaded through every
customer touchpoint.
- POST /partners/:id/revoke accepts { reason?, notify? } (default
notify=true). Reason persists on Partner.revokeReason.
- Sends partnerRevokedEmail on revoke when notify=true, with the
reason in-line.
- /auth/signin for an activated-but-revoked partner silently 200s
(anti-enumeration) AND sends a suspension-notice email instead
of a magic link — short-circuits the "why doesn't my link work?"
loop without breaking the enumeration guarantee.
- Reinstate clears revokeReason alongside revokedAt.
- Admin UI: click Revoke → dialog with reason textarea + "Email the
partner" checkbox (default on). Reinstate stays one-click.
New migration for Partner.revokeReason. Two new tests (notify=false
suppresses; signin after revoke sends the notice). 52 api tests,
63 across workspaces, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Settings page at /admin/settings lets the admin edit runtime
content without shell access:
- Program name — replaces "OpenPartner" in the sidebar brand
- Support email — rendered in the sidebar footer as a mailto:
link for partners to contact
Backed by the Config table, keyed 'program_settings'. GET /config/program
is auth-only (admin + partner sessions can both read). POST is admin
only. Partner portal fetches on mount with a 60s staleTime so edits
propagate quickly without hammering the endpoint.
Admin's Partners table now wraps each partner's email in mailto: too,
so one click opens a reply with that partner.
Env remains reserved for secrets + build-time properties; everything
user-facing goes through this pattern instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three tightly-coupled changes:
1. Admin as a first-class persona (not just ADMIN_API_KEY)
- New Admin table (id, email, name, activatedAt, revokedAt,
revokeReason, lastSignInAt).
- Session + MagicLinkToken generalized from partnerId to
(principalKind, principalId) so the same magic-link infra carries
both admins and partners. Backfill stamps existing rows
principalKind='partner'.
- Unified /auth/signin and /auth/magic/verify branch on the token's
principalKind — admins get adminInviteEmail + adminSigninEmail,
partners get their existing templates.
- /admins routes: list, invite, resend, revoke, reinstate. Revoke
has two guardrails: can't revoke yourself (session-sourced), and
can't drop active-admin count to zero.
- ADMIN_API_KEY env stays as bootstrap / headless / CI path; human
admins sign in via the account instead.
- Portal gets an Admins page under Admin; principal chip shows
"admin (env)" for env-bearer, admin name for session admins.
2. SMTP support via nodemailer
- Mailer auto-select: SMTP_HOST + MAIL_FROM → SMTP, else
POSTMARK_SERVER_TOKEN + MAIL_FROM → Postmark, else console
fallback (dev only).
- SMTP covers the universe of providers (Gmail, Workspace, SES,
Mailgun, SendGrid, Resend, Postmark SMTP, self-hosted Postfix).
- Postmark stays as a dedicated adapter.
- .env.example documents the two transport options.
3. /install wizard — WordPress-style first-run
- New InstallPage at /install, unauthenticated. Single form
collects admin name+email + program name + support email.
- GET /install/status lets the portal route to /install while
needsSetup=true and back to normal once an admin is activated.
- POST /install is rate-limited and refuses (409) once any active
admin exists — second installer can't take over.
- Submit creates the Admin row + saves program_settings +
sends the magic-link invite in one round-trip.
New tests (4) cover: install → first-admin happy path; admin invite +
signin; last-active-admin revoke guard; duplicate-email 409. Partners'
existing revoke had to switch Session lookup from partnerId to
(principalKind, principalId) too.
56 api / 3 router / 11 sdk = 70 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mail transport + credentials now live in the Config table and the
admin edits them from Settings → Email delivery. Rotating SMTP
passwords or Postmark tokens no longer requires a redeploy.
Encryption at rest:
- New SECRETS_ENCRYPTION_KEY env (32 bytes hex/base64), required in
production. Dev uses a fixed fallback with a warning.
- AES-256-GCM envelope (12-byte IV + 16-byte tag + ciphertext, base64).
- Only secret fields are encrypted (SMTP password, Postmark token).
Host/port/user/from stay plaintext — identifiers, not secrets.
- Public readers (the Settings UI) get a sanitized view: `hasPassword`
/ `hasToken` booleans instead of plaintext.
Resolution order at dispatch time:
1. UI-configured settings (Config table) win
2. Env vars (SMTP_HOST / POSTMARK_SERVER_TOKEN + MAIL_FROM) as fallback
3. Console (dev only)
Install wizard grows an Email delivery step: provider (SMTP / Postmark
/ None), from address, and all the fields for the chosen provider.
Settings page mirrors it with "saved ✓" indicators on fields whose
secret is already stored (leave blank to keep, enter to rotate).
The mailer now creates a transporter per-send so rotating credentials
from the UI takes effect on the next email without a restart. Tests
keep injecting a capturing mailer via __setMailerForTests.
.do/app.yaml, docker-compose.prod.yml, ci.yml get SECRETS_ENCRYPTION_KEY
wired in. .env.example rewrites the mail section to explain the UI-first
flow with env as fallback.
56 api / 3 router / 11 sdk tests all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single-form install grew oppressive. Break it into YOU → PROGRAM → EMAIL DELIVERY with a stepper up top, Back/Continue navigation, and the final step submitting. Validation is per-step (can't advance without the required fields for that step). Same submit body; only the UX changed.
README adds a What's implemented subsection grouping by area (attribution + payouts, auth + personas, configuration, integration surface, operations). Quickstart leads with the /install wizard and keeps the curl bootstrap as a headless / CI alternative rather than the default. ARCHITECTURE gains sections on personas (Admin + Partner, magic-link auth, revoke semantics) and Settings + secret encryption. docs/deploy.md now calls out SECRETS_ENCRYPTION_KEY as a required production env and documents that mail env vars are fallbacks rather than the primary path.
…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>
Newly-activated partners used to land on an empty Dashboard with nothing to do. The card fills that gap — two checklist rows that auto-complete as the partner finishes each step, and the card disappears once both are done: ☐ Create your first share link → /links ☐ Connect Stripe to get paid → /connect Live from the partner's existing data: link count via GET /partners/:id/links, Stripe Connect readiness via /connect/status. The existing ConnectNudge (shown when there's actually money waiting to be paid out) suppresses while the checklist is up — one call to action at a time. Also removed the dead Network-role redirect in the Dashboard component (network_creator / network_vendor roles no longer exist since the Network carve-out).
Adds a Rewardful-style integration path where merchants pass the partner ref through Stripe Checkout's client_reference_id instead of calling op.identify() from their app. On checkout.session.completed we stitch an Identity (cref → customer.id) and emit signup; downstream invoice.paid and customer.subscription.created resolve through the existing path. Disambiguator: the existing handleConnectEvent for checkout.session.completed now skips when client_reference_id is set, so merchant→customer checkouts can't accidentally clobber the merchant's own subscription record. resolveUserIdFromCustomer falls back to an Identity-table lookup when metadata is missing — covers the race where invoice.paid lands before our metadata backfill propagates on Stripe's side. SDK: getReferral() helper with the canonical Stripe Checkout usage in the docstring. Aliases getCref() so existing integrations keep working. Tests: 5 cases — valid stitch, unknown cref dropped, idempotency on Stripe event-id retries, invoice.paid resolves via the stitched Identity, and the merchant-subscription path still fires when no client_reference_id is set. Stripe customer ops mocked via vi.mock; signature verification real. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stripe's Event destinations UI splits platform-account events (checkout.*, customer.*, invoice.*) and connected-account events (account.updated, transfer.*) into separate destinations, each with its own signing secret. Both destinations point at the same /webhooks/stripe URL, so we just need to verify against any configured secret. STRIPE_WEBHOOK_SECRET now accepts either a single secret (existing behavior) or a comma-separated list. We try each in turn until one verifies; if none do, return 400 invalid_signature as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One-shot, idempotent script that creates OpenPartner Flex, Network access, and Revshare products in any Stripe account. Outputs the STRIPE_FLAT_PRICE_ID value to add to .env. Run with: STRIPE_SECRET_KEY=sk_test_... node apps/api/scripts/setup-stripe.mjs Re-runs are safe — products are looked up by metadata key before create, and prices by amount + recurrence kind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the major payments-readiness gaps before deploy.
Refund + reversal handling
- Map charge.refunded → reverse non-paid Commissions linked to the
original invoice via metadata.stripeInvoiceId. Already-paid
Commissions stay paid and the count surfaces on the refund Event for
admin attention (no automated clawback in v1).
- Map charge.dispute.created and invoice.payment_failed → corrective
audit Events; no auto-reversal (disputes can be won; failed invoices
never fired invoice.paid in the first place).
- Skip attribution on corrective event types so we don't create phantom
negative commissions.
Metered usage billing
- setup-stripe.mjs provisions Stripe Meters (openpartner_attributed_gmv,
openpartner_network_payouts) and metered Prices for Flex (1.5%),
Revshare (3%), and Network access (3%).
- usage-billing.ts aggregates attributed GMV between the last-reported
high-water mark and now, then reports to Stripe Billing Meter Events.
Idempotent via Stripe identifier; high-water mark only advances on
success.
- POST /billing/report-usage triggers manual reporting (admin scope).
Cron entry can be wired up later.
V2 Accounts Checkout fix
- Stripe Accounts V2 in test mode rejects Checkout sessions without a
pre-created Customer. /billing/checkout now creates (and reuses) a
Customer on first call, stamps it on Config, then passes it to
Checkout. Same record is used by Customer Portal after subscription.
Other
- /billing/checkout supports both flat (base + metered) and revshare
(metered-only) modes. Line items differ; same Customer reuse logic.
- Fix setConfig: jsonb column rejects raw strings; serialize through
JSON.stringify and cast for primitive values.
- Tests force OPENPARTNER_MODE=selfhost so vitest's auto-loaded .env
can't bleed in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scheduler.ts: croner-based, runs usage-report (daily 03:15 UTC) and runPayouts (Mon 09:00 UTC). Gated behind OPENPARTNER_ENABLE_SCHEDULER=1 so dev/test/CI don't fire scheduled jobs unexpectedly. protect: true prevents overlap when a job runs longer than its interval. - .do/app.yaml: adds STRIPE_FLAT_USAGE_PRICE_ID, REVSHARE/NETWORK price ids, and OPENPARTNER_ENABLE_SCHEDULER=1. Postgres flipped to production: true (paid tier, daily backups). - docs/deploy-production.md: end-to-end runbook for first launch on DO App Platform — secrets, DNS, Stripe webhook destinations, smoke checks, troubleshooting. Verified the api Docker image builds and runs cleanly against a fresh empty Postgres: all 19 migrations apply, /health returns 200, scheduler correctly logs its disabled state in dev. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rather than provisioning a dedicated managed Postgres cluster, OpenPartner
points at an existing cluster (e.g., a separate database inside the
Coherence cluster). DATABASE_URL becomes a SECRET env var on api + router
instead of a templated reference to the embedded ${openpartner-db.DATABASE_URL}.
Block is preserved in a comment so re-enabling a dedicated cluster later
is a paste-back rather than a recall.
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>
The portal was desktop-only (inline CSS-in-JS, fixed 248px sidebar, no media queries). Add a mobile-responsive layer: - useIsMobile/useMediaQuery hook (768px) — branch inline styles by viewport - Both shells (Shell + CreatorShell) collapse the fixed sidebar into a hamburger top bar + slide-in drawer on mobile; sidebar takes a variant prop - Page/Table primitives: responsive padding + stacking header; tables get a horizontal-scroll wrapper - op-grid-collapse class (global.css, !important media query) collapses 22 hardcoded multi-column grids to one column on phones Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| esbuild@0.21.5: | ||
| resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} | ||
| engines: {node: '>=12'} | ||
| esbuild@0.25.12: |
There was a problem hiding this comment.
Risk: Affected versions of esbuild are vulnerable to Download of Code Without Integrity Check / Untrusted Search Path. esbuild's Deno distribution module (lib/deno/mod.ts) contains an import.meta.main CLI entrypoint that calls install() directly when the module is run as a script (deno run https://deno.land/x/esbuild@vX/mod.js). This download path has no SHA-256 integrity verification: if NPM_CONFIG_REGISTRY resolves to an attacker-controlled registry, the fetched binary is executed immediately, yielding arbitrary code execution without any API call in user code.
Manual Review Advice: A vulnerability from this advisory is reachable if you invoke the esbuild Deno module directly as a CLI tool (e.g. deno run https://deno.land/x/esbuild@vX/mod.js) and the NPM_CONFIG_REGISTRY environment variable resolves the binary download to an untrusted registry
Fix: Upgrade this library to at least version 0.28.1 at openpartner/pnpm-lock.yaml:1774.
Reference(s): GHSA-gv7w-rqvm-qjhr
🧼 Removed in commit d72dee4 🧼
| vitest@1.6.1: | ||
| resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} | ||
| engines: {node: ^18.0.0 || >=20.0.0} | ||
| vitest@3.2.4: |
There was a problem hiding this comment.
Critical severity vulnerability may affect your project—review required:
Line 2932 lists a dependency (vitest) with a known Critical severity vulnerability.
ℹ️ Why this matters
Affected versions of vitest are vulnerable to Missing Authorization. When the Vitest UI server is listening, the deprecated isFileServingAllowed check is applied without normalizing the URL before filesystem operations, allowing path traversal that lets an attacker read, write, and execute arbitrary files outside the project directory.
References: GHSA
To resolve this comment:
Check if you run the Vitest UI on Windows, or you expose the Vitest UI server to the network with the --api.host flag or api.host config option.
- If you're affected, upgrade this dependency to at least version 3.2.6 at pnpm-lock.yaml.
- If you're not affected, comment
/fp we don't use this [condition]
💬 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.
- Table: reflow to stacked label/value cards on phones instead of horizontal scroll, so all data is visible without sideways scrolling - PartnerCard / creator card stats keep 2 columns on mobile (don't collapse short stat pairs to a single column) - Webhook endpoint card: stack header vertically on mobile, wrap long URLs, and let action buttons wrap so they no longer overlap the URL Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd billing gate (#29) * fix(portal): refine mobile layouts from device testing - Table: reflow to stacked label/value cards on phones instead of horizontal scroll, so all data is visible without sideways scrolling - PartnerCard / creator card stats keep 2 columns on mobile (don't collapse short stat pairs to a single column) - Webhook endpoint card: stack header vertically on mobile, wrap long URLs, and let action buttons wrap so they no longer overlap the URL Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(white-label): implementation spec for white-label custom domains Adversarially-reviewed spec for white-labeling the hosted partner portal: billing-gated whiteLabel entitlement, DO App-Platform-native custom-domain edge for MVP (droplet/Caddy on-demand-TLS deferred to Phase 3 self-serve), host-based tenant resolution, per-tenant PORTAL_URL, and Network isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(white-label): Phase 0 foundation — entitlement + brand payload + mailer - Migration + TenantRow.whiteLabel (provisioned flag). - isWhiteLabelEntitled(): effective = provisioned AND entitling billing state (enterprise / active sub / in-trial / self-host), so a lapsed unpaid trial can't keep white-label for free. - /config/program now returns effective whiteLabel so the portal can key branding removal + Network-hiding on it in the fetch it already makes. - mailer drops the "via OpenPartner" From suffix for white-label tenants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(white-label): isolate white-label tenants from the Network Source-side federation suppression so a white-label tenant is never discoverable on the OpenPartner Network (§7.4 launch-minimal isolation): - Data-plane guarantee: dispatch() (all Network pushes) and sendHeartbeat() (liveness) no-op for white-label-effective tenants, regardless of a stale membership row — so an existing federated tenant that turns white-label stops publishing and gets pruned from creator search. - Enable routes (/config/network, start-connect, auto-enroll, complete-connect) 409 white_label_network_conflict (defense in depth). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(portal): white-label branding removal + Network surface hiding Phase 0 portal sweep, keyed on the effective `whiteLabel` flag from /config/program via a new shared useBrand() hook: - Brand-identity strings now resolve through programName (falls back to 'OpenPartner' when not white-label, so non-white-label render is unchanged). - Network/Discover surfaces (nav, routes, marketplace onboarding/listing, "List on the OpenPartner Network") are hidden when white-label-effective. - BrandMark renders a neutral monogram for a white-label tenant with no uploaded logo, instead of leaking the OpenPartner logo mark. - Mailer/admin-only and cross-tenant creator surfaces left as the real product brand (documented in the spec). Plus a DB-free unit test for the billing-aware isWhiteLabelEntitled gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(white-label): record Phase 0 shipped status + residual gaps Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(white-label): spec multi-brand billing + add-brand rework (§13) Locks the Phase-1 fix: authenticated add-brand flow (reuse identity email, attach to platform bundle, plan-gated), a plan-required backstop gate, and white-label-requires-add-on — closing both the per-brand billing leak and the switcher-orphaning bug found in testing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(billing): require an active plan to onboard partners (§13) Splits the trial-gate into two tiers: - PLAN_REQUIRED (POST /partners, Network request approve): needs an active subscription (Flex/RevShare sub, enterprise, or self-host) REGARDLESS of trial — a brand must activate a plan before onboarding its first partner, per the per-brand billing policy. Returns 402 plan_required. - TRIAL_GATED (program/coupon/campaign/import/offerings): unchanged soft gate after trial lapse. hasActivePlan() treats "plan picked but never checked out" (no stripeSubscriptionId) as NOT active — closing the multi-brand billing leak. DB-free unit tests cover the gate logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(white-label): PortalCustomDomain table + host-based tenant resolution (§3.2, §4.3, §7.5) - PortalCustomDomain migration: globally-unique domain, rotating verificationToken, non-terminal status, edgeKind, RLS + app-role grant. Exported as a documented sidecar table (§3.3 portability). - tenancy.ts: resolveTenantFromHost ahead of path-based resolution. X-Forwarded-Host honored ONLY behind a timing-safe X-OP-Edge-Token match (EDGE_TRUST_SECRET); otherwise the genuine Host header. Never req.hostname (XFH-tainted under trust proxy). Platform hosts and *.ondigitalocean.app always take the path branch. - Host resolver deliberately NOT gated on whiteLabel — graceful branding revert on cancellation (§8.1); effective entitlement surfaced as req.tenantWhiteLabelEffective. - Spoof regression tests: forged XFH without a valid edge token resolves the genuine Host (§7.5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(white-label): per-tenant portal URLs + custom-domain-aware CORS (§4.4, §4.5) - portal-url.ts: getPortalBaseUrl(tenant) — custom domain > /t/<slug> > PORTAL_URL. buildMagicLinkUrl now takes a PortalLinkTenant and is the single chokepoint; all 7 tenant-scoped callers pass linkTenantOf(req) (middleware stashes Tenant.customDomain on both host- and path-resolved requests, so links target the custom domain even when the admin works from the platform URL). install/signin stay platform-based by design. - Stripe redirect URLs intentionally untouched — client-origin-derived. - CORS: origin callback over seed allowlist ∪ cached verified custom domains (60s TTL, invalidated on verify/revoke, DB-failure = deny). Prod boot-throw + no-reflection invariants preserved and pinned by test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(white-label): domain register/verify + allow-gate + revocation sweeps (§4.1, §4.2, §7.6, §8.1) - portal-domains.ts: shared lifecycle logic — FQDN/reserved-host/apex validation, chunk-aware TXT matching, rotating verification tokens, staleness TTL (7d), isDomainAllowed predicate, daily re-verification sweep (demote + rotate + clear routing on missing TXT) and entitlement sweep (revokes routing when whiteLabel && billingActive goes false — covers trials that lapse without ever subscribing). - routes/portal-domains.ts: public GET /portal-domain-allowed (Caddy ask gate: 200 iff verified + fresh + entitled) and tenant-scoped admin /config/domain register/verify/list/delete on the RLS pool. Registration + verify require the EFFECTIVE entitlement (402 otherwise); verify never short-circuits — always re-checks DNS. - Scheduler: portal-domain-reverify (04:45 UTC) + white-label-entitlement- sweep (04:55 UTC); selfhost skips both. DO-native edge removal is a logged hook point until Phase 2 wires the DO API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(white-label): public /branding bootstrap + pre-auth brand on sign-in (§4.7, §5.2) Closes the Phase-0 residual gap: /t/<slug>/login and custom-domain login showed 'OpenPartner' because /config/program requires auth. - API: public GET /branding (tenant from host or path via tenantMiddleware; platform scope returns nulls). Exposes only brand fields for the resolved tenant — the §4.3 edge-trust guard prevents reading another tenant's brand context via a forged host. - Portal: usePublicBrand() hook; AuthFrame renders the tenant brand pre-auth, with a neutral placeholder while loading and a monogram for white-label tenants without a logo (never the platform mark). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(billing): authenticated add-brand flow with mandatory plan step (§13.3) Replaces the switcher's '<a href=/signup>' (public form, blank email — the orphaning bug) with a first-class authenticated flow: - API: POST /me/brands (platform-session required). First Admin is ALWAYS the platform identity's email — switcher mismatch impossible — and is created activated, so the brand is enterable immediately. Plan choice (flex | revshare) is REQUIRED; enterprise is refused on self-serve endpoints (it would self-declare past the plan-required gate) — public /signup tightened the same way. Network auto-enroll extracted to autoEnrollBrandOnNetwork() and shared with /signup. - Portal: /brands/new page (name, slug, plan cards with honest per-brand billing copy) → creates brand → enters workspace → lands on admin/billing for checkout. Switcher item renamed to 'Create a new brand (own plan)'; Workspaces empty-state updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(white-label): root-mount the tenant SPA on custom-domain origins Without this, portal.xispark.com rendered the PLATFORM route table — '/' hit the marketing landing and /auth/magic hit the platform verify handler, so custom-domain magic links could never sign anyone in (§4.3+§4.4 atomicity). - /branding now returns the resolved tenantSlug; a prefix-less probe only carries a slug when the HOST resolved the tenant, i.e. the SPA is being served from a white-label domain. - App.tsx probes it in multi-tenant mode and, on a host-resolved origin, mounts /login, /auth/magic, and the Shell at root — same shape as single-tenant, which the tenantBase='' plumbing already supports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(api): align local test env with CI + pin host-only session cookie (§7.3) The DB-backed suites passed in CI (fresh DB, OPENPARTNER_MODE unset → selfhost) but failed locally: a developer .env says MODE=flat and the shared dev DB's default tenant carried a leftover billingPlan='flex' with an expired trial — both of which correctly trip the §13 plan-required gate and 402 partner onboarding. New vitest setup file pins MODE=selfhost (files that test hosted billing still override at module scope) and resets the seeded tenant's billing columns so every file starts from fresh-migration state. Full suite green: 176/176 including the integration tier against the PortalCustomDomain migration. Also adds the §7.3 regression test pinning sessionCookieOptions() as domain-less (host-only is what isolates white-label sessions per domain), and documents EDGE_TRUST_SECRET / DO_APP_DOMAIN_ALIAS / WHITELABEL_DROPLET_HOST in .env.example. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(white-label): record Phase 1 shipped status (§14) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): make setup-env.ts a module so top-level await compiles (TS1375) Dynamic import() doesn't confer module status; CI tsc and the docker api image build both failed on it. vitest was unaffected (esbuild transform), which is why the suite ran green locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump nodemailer to 9.0.1 and hono to 4.12.25 (high CVEs) pnpm audit --audit-level=high gates CI and two advisories published after main's last run flagged nodemailer <=9.0.0 (GHSA-p6gq-j5cr-w38f, raw-option file-read/SSRF bypass — option unused here) and hono <4.12.25 (GHSA-88fw-hqm2-52qc). Basic createTransport/sendMail usage is unchanged across the nodemailer major. Remaining 3 moderates are below the gate. Full workspace suite green (api 176, sdk 11, portal 8, router 3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| vite@5.4.21: | ||
| resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} | ||
| engines: {node: ^18.0.0 || >=20.0.0} | ||
| vite@6.4.2: |
There was a problem hiding this comment.
High severity vulnerability may affect your project—review required:
Line 2892 lists a dependency (vite) with a known High severity vulnerability.
ℹ️ Why this matters
Affected versions of vite and vite-plus are vulnerable to Exposure of Sensitive Information to an Unauthorized Actor / Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'). Vite's server.fs.deny blocklist—which protects sensitive files such as .env and certificate files from being served—can be bypassed on Windows using alternate path representations (NTFS Alternate Data Stream syntax like /.env::$DATA?raw, or 8.3 short filenames), allowing an attacker to read otherwise-denied files when the dev server is exposed to the network.
References: GHSA
To resolve this comment:
Check if you expose the Vite dev server or vite-plus to the network by configuring a non-loopback address using the --host CLI flag on Windows.
- If you're affected, upgrade this dependency to at least version 6.4.3 at pnpm-lock.yaml.
- If you're not affected, comment
/fp we don't use this [condition]
💬 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.
GitHub rejects advanced-configuration SARIF uploads while default
code-scanning setup is enabled ('CodeQL analyses from advanced
configurations cannot be processed when the default setup is enabled'),
so this workflow failed on every push while the default-setup analysis
already scans and passes. One scanner is enough; the default setup stays.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#31) Answers 'why isn't this automated?' — it now is: - do-app-domains.ts: DO Apps API read-modify-write (domains live in the app spec; there is no per-domain endpoint). registerAppDomain / removeAppDomain are idempotent, preserve every other spec field (services, envs, the PRIMARY platform domain + zone), never throw into caller flows, and no-op with a logged warning when DO_API_TOKEN / DO_APP_ID are unset. Customer CNAME target is derived from the app's default_ingress (DO_APP_DOMAIN_ALIAS overrides). - Domain verify now registers the domain on the DO app and returns an 'edge' field; admin domain deletion and both revocation sweeps remove it, so cert issuance and revocation follow entitlement automatically. - .do/app.yaml: domains block replaced with a hard warning — a spec-declared domain list + 'doctl apps update --spec' would clobber dynamically-managed customer domains. - docs/white-label-onboarding-runbook.md: full xispark onboarding sequence (entitle → assert Network isolation → register → verify → header-capture → e2e login), updated for the automation. - Unit tests pin the spec-mutation invariants (idempotence, PRIMARY domain preserved). Suite green: 181/181. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): self-serve add-on billing + admin wizard (Phase 3 §8.2)
Makes the full xispark path automated end-to-end: enable add-on in the
UI → Stripe subscription item → register domain → DNS → verify → cert.
- STRIPE_WHITELABEL_ADD_ON_PRICE_ID env + boot probe;
priceIdsForPlan(plan, {whiteLabel}) bundles the add-on into plan
Checkout (/billing/checkout accepts whiteLabel: true).
- GET/POST/DELETE /billing/white-label: status; enable attaches the
add-on price to the ACTIVE subscription with proration (enterprise =
flag only, sales-led; unsubscribed = 409 subscription_required);
disable removes the item AND revokes custom-domain routing + DO edge.
- Webhook: subscription.updated mirrors add-on presence onto
Tenant.whiteLabel (no-op when the price env is unset, protecting
manually-provisioned deployments); subscription.deleted disables +
revokes. applyWhiteLabelFromSubscription /
revokeTenantCustomDomainRouting unify webhook, sweeps, and admin
disable on one code path.
- Portal: admin → White label wizard — add-on toggle, domain register,
CNAME+TXT with copy buttons, verify with DO edge result, delete,
TXT-rotation guidance on failed verification. Nav + route added.
- Runbook rewritten around the self-serve path (curl kept as concierge
equivalents); spec §15 records Phase 2+3 status.
Suite green: 187/187.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portal): hide platform account/brand actions from white-label portals
The identity switcher's 'Add another account' and 'Create a new brand
(own plan)' rows are platform UX — they reference OUR accounts and
plans, and their routes only exist on the platform origin (dead links on
a custom domain). Gate them on !whiteLabel; the identity chip + sign-out
stay.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(billing): plan-gate public partner self-signup too (§13)
POST /partner-signup is partner onboarding — with auto_approve on, a
brand could grow its roster via creator self-signup without ever
activating a plan, bypassing the gate that covers admin POST /partners.
Route-matcher predicate exported + pinned by test (incl. the stay-open
list: ingest, redeem, reads, auth).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(portal): hosted /join partner-application page + signup-policy settings UI
The self-serve partner signup endpoint existed with no page in front of
it — brands had to embed their own form. Now every tenant gets a hosted,
brand-branded application page:
- /join on custom-domain and single-tenant origins, /t/<slug>/join on
the platform origin. Pre-auth branding via usePublicBrand; states for
auto-approve (check inbox), require-review (application received),
closed (signup_disabled or plan_required — same partner-facing copy),
and the endpoint's non-enumerating already-registered behavior.
- Settings → Partner signups card: open/closed toggle (disabled flag)
and auto-approve vs hold-for-review policy — the 'curated ecosystem'
control, surfaced. Shows the tenant's join URL. Invites always work
regardless.
This is deliberately NOT the platform Network-creator signup — a
white-label portal never gets that; /join signs partners up for the one
resident brand only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): withdraw from the marketplace when the add-on turns on (§7.4)
A previously federated brand's Vendor + Offerings lingered on the
Network's Discover until stale-heartbeat pruning caught up — and worse,
a program save with shareOnNetwork=true could RE-publish an offering
(the marketplace sync uses the proxy, which unlike dispatch() had no
white-label suppression).
- withdrawTenantFromMarketplace(): unpublishes every offering with a
networkOfferingId on the white-label-enable transition (webhook,
billing endpoint, enterprise path — all share
applyWhiteLabelFromSubscription). Unpublish, not delete: history and
the road back stay intact if the brand later drops the add-on.
- syncCampaignToMarketplace now no-ops for white-label-effective
tenants, closing the re-publish leak.
- Runbook: the isolation assertion is now automated-with-a-log-check;
manual assert kept for SQL-provisioned tenants.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portal): hide the 'via Network' provenance chip on white-label portals
Audit of Network UI under white-label: nav sections, admin+partner
network routes, Dashboard CTAs, and the program form's marketplace
fields were all already gated (Phase 0). The one reachable leak was the
'via Network' grant-source chip on the partner-programs page, visible to
a white-label brand with pre-white-label federation history. Grants keep
working; they just don't advertise the shared marketplace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(portal): render-level white-label branding regression guard (§5.1)
Renders the real Sidebar (admin + partner) and pre-auth AuthFrame with a
white-label brand and asserts no /openpartner/i and no /\bnetwork\b/i
in the DOM (plus no platform logo img). Inverse case proves the
assertions can fail. RTL + jsdom added as portal devDeps; jsdom scoped
per-file via the vitest environment pragma.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(api): drop unused subscriptionHasWhiteLabel import from billing routes
Only the webhook consumes it; billing.ts uses
applyWhiteLabelFromSubscription + whiteLabelPriceId. Flagged by code
scanning on #32.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ard (#33) An unsubscribed admin clicking 'Enable white-label add-on' got a red 'go to Billing, then come back' error — and Billing has no add-on option, so the round trip ended in a second Stripe operation anyway. The API already supported bundling (billing/checkout whiteLabel:true); now the wizard uses it: no active subscription → the button becomes 'Subscribe with white-label included' and opens one Checkout carrying the plan + add-on line items. The webhook flips whiteLabel on completion, so the page is active when Stripe redirects back. Enterprise keeps direct-enable; no-plan-chosen still points to Billing (plan selection lives there). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sales-issued codes (e.g. first-month white-label discounts) had no way to be redeemed — Checkout only renders the code field when the session sets allow_promotion_codes. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
POST /config/mail/test sends a test message through the SAVED mail
settings to the signed-in admin's inbox (API-key admins pass an explicit
to). Transport errors ('535 auth failed', ECONNREFUSED, …) surface
verbatim in the UI instead of a partner invite silently failing later.
Result line reminds that unsaved changes aren't tested.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…tion on failed manual attempts (#36) Rotating the verification token on every failed verify punished the normal flow: customer publishes the TXT, admin clicks Verify during DNS propagation lag, the failure rotates the token, and the record just published is now permanently wrong — retry could never succeed without another DNS round-trip. Rotation adds no security on a failed button click (same admin, same claim; TXT values are public anyway). It matters when ownership may have CHANGED — so it now happens only on demotion by the daily re-verification job (§7.6), which is unchanged. Also: a VERIFIED domain failing a manual re-check is reported but not demoted — demotion/revocation authority stays with the daily job, so a transient DNS blip during a hand-run check can't take a live customer portal down. lastCheckedAt still only records successful proof. Wizard + runbook copy updated; spec notes the refinement. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(mailer): bound SMTP timeouts so transport failures beat the gateway timeout Verified in prod (xispark mail-test): a host that silently drops SYNs (xispark.com:465) hung nodemailer for its DEFAULT 2-minute connectionTimeout — longer than Cloudflare's ~100s proxy limit — so the admin saw an opaque 504 instead of our 502 with the transport error. Real sends (partner invites, signup) could pin requests just as long. connectionTimeout/greetingTimeout 10s, socketTimeout 30s: a healthy handshake completes in single-digit seconds. Also rewrites the test-result hint (the old one implied unsaved changes even when settings were saved) to point at host/port/credentials with the timeout≈wrong-hostname heuristic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): plain-language placeholder for stored secrets Bullet-character placeholders read as 'your password is still in this field' (user report from xispark) — bullets are the universal signal for a present value. The field's value is actually cleared on save and the secret is write-only server-side; say so in words instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
) The browser SDK's stitch call comes from the BRAND'S OWN website origin (acme.com) — never in our allowlist — so identify() was CORS-blocked for every hosted tenant unless ops hand-added their site to CORS_EXTRA_ORIGINS. The endpoint is deliberately unauthenticated, rate-limited, cookie-free, and returns nothing sensitive, so it gets analytics-collector CORS: any origin, NO credentials. Every other route keeps the strict allowlist + credentials (pinned by test). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
) index.html ships hardcoded 'OpenPartner' + the platform favicon: the one pair of brand marks no in-app setting, cache clear, or incognito window can change. First real-world report came from a white-label admin who 'changed the name and logo but it still appears' — in the browser tab. BrandDocument (mounted once in App) sets document.title, swaps the favicon, and rewrites og:title from the public branding payload: white-label → brand name + brand logo (or a brand-colored SVG monogram — never the platform mark); branded non-white-label → 'Brand · OpenPartner'; platform surfaces keep the shipped defaults. usePublicBrand now keys its cache by the /t/<slug>/ prefix so SPA navigation between platform and tenant surfaces can't serve stale branding, and exposes brandColor. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(portal): brand the HTML document — tab title, favicon, og:title index.html ships hardcoded 'OpenPartner' + the platform favicon: the one pair of brand marks no in-app setting, cache clear, or incognito window can change. First real-world report came from a white-label admin who 'changed the name and logo but it still appears' — in the browser tab. BrandDocument (mounted once in App) sets document.title, swaps the favicon, and rewrites og:title from the public branding payload: white-label → brand name + brand logo (or a brand-colored SVG monogram — never the platform mark); branded non-white-label → 'Brand · OpenPartner'; platform surfaces keep the shipped defaults. usePublicBrand now keys its cache by the /t/<slug>/ prefix so SPA navigation between platform and tenant surfaces can't serve stale branding, and exposes brandColor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portal): explain the destination-URL rule inline on program create The API requires a full URL (z.string().url()); typing 'yourbrand.com' without the scheme bounced an opaque 400 — five times in a row for the first white-label customer. The form now validates inline, says what a valid value looks like, offers a one-click 'Use https://…' fix when the input is scheme-less but otherwise a plausible host, and gates the submit button on validity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(portal): separate favicon + brand-color theming across the app
Favicon (distinct from logo — square mark vs lockup):
- Tenant.faviconUrl migration + /uploads/favicon (PNG/JPEG/WebP only; no
SVG/ICO — SVG is a script vector and PNG favicons render everywhere).
- Settings → Brand info gains a second uploader (LogoUploader generalized
to BrandImageUploader); /branding + /config/program carry faviconUrl.
- BrandDocument favicon preference: faviconUrl → logoUrl → monogram.
Brand-color theming (spec §5.4, finally applied):
- Every accent token in theme.ts is now a CSS custom property with the
platform teal as fallback; BrandDocument derives and injects
--op-accent* from Tenant.brandColor (hover = shaded, ink = black/white
by WCAG luminance, soft + alpha tints precomputed — inline styles can't
color-math a var()). Buttons, links, and active nav re-tint app-wide;
no brandColor → properties removed → teal default.
- Swept all ${theme.accent}NN hex-suffix concatenations (which would
break against var() values) to explicit accentA* tokens; theme.ts
comment forbids reintroducing the pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(uploads): prove req.body is a Buffer before reading .length
CodeQL (js/type-confusion-through-parameter-tampering) flags reading
req.body?.length before the type is proven — with express.raw a
mismatched Content-Type leaves req.body as {}. Behavior-identical
reorder: the Buffer guard now precedes validateImageUpload at all three
upload sites (logo, favicon, brand assets), which resolves the recurring
alert structurally instead of dismissing each new copy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(uploads): use a guarded local for the raw body — CodeQL doesn't narrow req.body
The reorder in the previous commit wasn't enough: CodeQL treats every
fresh req.body access as a new tainted read and doesn't narrow it
through Buffer.isBuffer(req.body). Its own suggested changeset shows the
requirement — bind to a local after the guard and use only that. Done at
all three upload sites (logo, favicon, brand assets); behavior identical,
and TypeScript narrows the unknown-typed local through the same guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(storage): normalize the Content-Type header union inside validateImageUpload
Third and final round with js/type-confusion: after the guarded-local
body fix, the query's remaining tainted argument was
req.header('content-type'). validateImageUpload now accepts the raw
string | string[] | undefined union and normalizes with an explicit
Array.isArray branch — the sanitizer shape the query recognizes, and
genuinely more robust against duplicate Content-Type headers. One place,
covers all three upload sites. If the rule fires again after this, the
remaining step is dismissal with justification, not more contortion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nblocks campaign creation (#42) Real-world blocker (first white-label customer): defaultShareOnNetwork returns the Network membership flag, which white-label tenants still have (the row is deliberately retained for the road back) — so campaign creation defaulted shareOnNetwork=true, then 400'd requiring a marketplace description for a surface their UI correctly hides. Every formerly-federated white-label brand was fully unable to create campaigns. - defaultShareOnNetwork: false when white-label is effective - POST /programs: force false (even explicit true) before the description requirement - PATCH /programs/:id: pin false on every edit — also normalizes pre-white-label rows still carrying true, so a lapsed add-on can't resurrect a stale listing Belt-and-suspenders with the existing sync guard (which made the flag inert at publish time but not at validation time — validation is where it hurt). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ere dead (#43) DO App Platform's ingress strips the matched /r prefix before forwarding (same as it strips /api), so in production the router received /<key>, matched no route, and returned Hono's default 404 — on EVERY hosted domain, platform and custom alike. The api component survives identical stripping because its routes are mounted prefix-less; the router's weren't. - Mount both shapes: /r/:slug/:linkKey + /r/:linkKey (dev, self-host proxies, direct hits) and /:slug/:linkKey + /:linkKey (stripped). /health is static and Hono prefers static over params. - Key-only lookups are now HOST-SCOPED on white-label custom domains (Host → Tenant.customDomain → tenant-bound Link query), so portal.brand.com/r/<key> resolves the right tenant's key and two tenants may share a key string. Unknown hosts keep the global single-tenant fallback. - Links page: share keys render as full copyable URLs matched to the context — /r/<slug>/<key> on the platform origin, /r/<key> on a custom domain, relative display on self-host (portal origin isn't guaranteed to serve /r). /branding + usePublicBrand expose tenantSlug for the custom-domain detection. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(payouts): fail closed on unfunded hosted Connect payouts + per-tenant mode Independent audit (2026-07-10, confirmed by two reviews) established that hosted Connect payouts transfer the FULL commission principal from the platform's Stripe balance with no mechanism collecting it from the brand — the metered percentage covers the service fee, not the money sent. Until the funding-batch flow exists: - Hosted tenants' stripe_connect payouts are REFUSED: commissions stay 'approved', no Payout row is written (so retries don't accumulate failure rows), and each refusal logs loudly with tenant/partner/amount. Self-host is unaffected — there the platform account IS the brand's. OPENPARTNER_ALLOW_UNFUNDED_CONNECT_PAYOUTS=1 is a deliberate operator escape hatch. - runPayouts now uses the TENANT's billing mode instead of global OPENPARTNER_MODE (audit finding: the hosted deployment sets 'flat' globally, so revshare tenants' platformFee was computed as 0). Manual-rail payouts are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop AGENTS.md from this branch — local tooling file, not part of the payout guard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…r adversarial review (#46) * docs(payouts): hosted funding spec — invoice-before-transfer (draft for review) Design for issue #45: reserve approved commissions into hosted-only sidecar batches, invoice the brand for exactly the batch principal, transfer to partners only after settled funding (source_transaction- linked, deterministic idempotency keys, per-transfer transactions, advisory-locked runners). Folds in the audit's integrity cluster (double-pay keys, tx-across-Stripe, reversal semantics, manual-rail confirmation) since the runner is rebuilt around a state machine anyway. Open questions for the founder in §10; rejected alternatives in §11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(payouts): funding spec v2 — all 14 adversarial-review findings incorporated Blockers fixed in design: 1. Release protocol: release_requested state, PI terminalized BEFORE allocations free, payment-wins on the cancel-vs-success race — closes the released/late-payment double-pay sequence. 2. HostedFundingTransfer intent rows with frozen keys, snapshotted destinations, and reconcile-by-transfer_group/metadata after the ~24h idempotency window — never blind re-POST. 3. Collection primitive switched Invoice → dedicated off-session PaymentIntent (invoice semantics too permissive: pending-item leakage, discounts, credit-balance and out-of-band satisfaction); funding confirmation verifies the live PI + charge, amount, currency. 4. Guarantee corrected: principal collected before transfer, residual refund/return/dispute exposure retained and MANAGED — funding_disputed state, reversal attempts, brand receivables, per-tenant risk caps. 5. Allocation state machine + lifecycle interlocks on commission reverse/refund/fraud paths; transferred commissions immutable, compensating CommissionAdjustment entries only. Must-fixes: multi-step collection with per-step intents; webhook inbox + CAS transitions + transactional outbox + transfer metadata for tenant resolution; normative transition table incl. invoicing / payment_processing / release_requested / funding_disputed / settled_with_residual / recovery_required; residual dispositions for funded-but-untransferable allocations; corrected reservation SQL (row locks, group in app code); amount-based PayoutReversal rows with derived partial states; integer minor units + USD-only launch + fee reserve; HostedBillingState mirror separating service from funding eligibility; reconciliation job + failure-mode test matrix in rollout. Founder decisions recorded (§12): absorb fees, per-tick cadence, one open batch/tenant/currency, $25 floor, ToS + awaiting-brand-funding partner status, counsel review before prod enable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(payouts): rail-differentiated processing fees — card funding carries a flat 3% fee line Founder caught the economics hole in blanket absorption: processing cost scales with commission PRINCIPAL while fee revenue scales with GMV, so card-funded absorption burns 2.9% × commissionRate / feeRate of revenue — ~97% of Flex's 1.5% on a 50%-commission program. Revised: bank-debit funding absorbed (0.8% capped $5 — negligible), card funding charges principal + flat 3% platform processing fee (fee framing vs surcharge rules added to the counsel checklist). Dispute/return reserve unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(payouts): at-cost card fee + per-tenant funding authorization gate - Card funding fee revised flat-3% → at-cost 2.9% + $0.30: the live pricing page promises payout fees are 'passed through directly and not marked up', and a flat 3% would have broken that claim. Premium-card variance absorbed. - New §12 decision: one-time per-tenant funding authorization (explicit in-app acceptance, stored with terms version) before any funding batch — satisfies Stripe's off-session prior-agreement requirement and covers tenants whose payment methods predate the feature. New brands also accept ToS at plan Checkout via consent_collection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(payouts): fee design corrected per second review round — ACH-only launch Review blockers on the fee design, all adopted: - Naive principal-based 2.9%+30¢ UNDER-COLLECTS (Stripe's percentage applies to the gross charge — ~$8.42 short on a $10k batch), a miniature of the unfunded-flow bug. Card path now requires exact gross-up F=(0.029P+0.30)/(1−0.029) + balance_transaction.fee true-up, OR a published fixed fee with all 'at cost / no markup' language dropped. - Spec self-contradictions fixed: §5 PI amount and §6 verification now use grossChargeMinor; batch model gains grossChargeMinor, quotedFeeMinor, actualStripeFeeMinor, paymentMethodType, pricingVersion; §2 'fee-absorbing' overclaim removed. - LAUNCH IS BANK-DEBIT-ONLY (payment_method_types restricted); card funding disabled and hard-gated on counsel (surcharge classification, credit-vs-debit, disclosure, registration). ACH costs absorbed but tracked explicitly as rail cost incl. $4 failed-payment/$15 dispute fees. - Fee shown in dollars before authorization; OpenPartner-generated receipt per funding charge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(payouts): region-aware funding rails + bank-debit setup in the authorization gate xispark is UK-based, which exposed two gaps: - Rail is now selected per (brand bank country × batch currency): launch ACH/USD, Bacs Direct Debit/GBP as the designed-in fast-follow (DDG indemnity window added to dispute model + counsel list). No-rail pairings (UK brand + USD commissions) stay manual, or supervised cost-absorbed card at operator discretion per review recommendation. xispark launches on the manual rail regardless. - The authorization gate now includes bank-debit SETUP (Financial Connections / Bacs mandate) — subscriptions are card-paid, so no tenant has a bank instrument on file; this applies to US brands too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nbox, adjustments (spec §4) (#47) Foundation for hosted payout funding (docs/payout-funding.md, issue #45): - HostedFundingBatch: minor-unit money columns (principal, grossCharge, quotedFee, actualStripeFee for rail-cost telemetry), full state-machine CHECK, partial-unique open-batch-per-tenant×currency index. - HostedFundingAllocation: per-commission reservation with THE mutual- exclusion partial unique index (live allocation per commission — spec blocker 1) and its own lifecycle state. - HostedFundingTransfer: transfer intents created before any Stripe call — frozen idempotency keys, snapshotted destinations, unique stripeTransferId (spec blocker 2). - HostedFundingAuthorization: the per-tenant gate — terms acceptance + verified bank-debit instrument; no batch without it. - HostedBillingState: webhook-mirrored subscription status (dunning gap). - StripeWebhookInbox: event-id dedup (platform-scoped, privileged pool). - PayoutReversal: amount-based reversals (partial-reversal capable). - CommissionAdjustment: CORE portable compensating-entry ledger; added to EXPORTABLE_TABLES after Commission for FK-safe import. - Payout.stripeTransferId partial unique index (double-record guard). All Hosted* tables RLS'd + app-role granted, exported as documented sidecars. Migration applied locally; suite green 192/192. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…confirmation (spec §5–§7) (#48) Build 2 of the hosted payout funding system (#45, spec: docs/payout-funding.md). - funding/state.ts: CAS transition primitive (casBatch), launch constants (USD-only, $25 batch floor, 10-day funding timeout), minor-unit money helpers, per-tenant advisory payout lock - funding/reserve.ts: reserveFundingBatch — freezes approved commissions into a HostedFundingBatch under FOR UPDATE SKIP LOCKED row locks + live-allocation exclusion; one open batch per tenant×currency; commissions roll forward past an open batch - funding/collect.ts: runFundingCollector (5-min scheduler job) — creates off-session us_bank_account PaymentIntents with frozen idempotency keys (fbpi:<batchId>), reconciles crashed 'invoicing' batches via metadata search (never blind re-POST), polls payment_processing as a webhook-loss backstop, owns the retry schedule (~day 1/3/7) and the 10-day release timeout - funding/release.ts: release protocol — CAS to release_requested, terminalize the PI first, payment WINS races; allocations freed only after the money side is provably dead - funding/confirm.ts: confirmFundingFromPaymentIntent — verifies the live PI (status/id/amount/currency/charge) before CAS to funded; released+succeeded escalates to recovery_required with a loud alert - payouts.ts: runPayouts now serializes on the tenant advisory lock; eligible hosted Connect groups (funding enabled + authorized + transfer-ready partner + USD) route into reservation instead of the fail-closed guard — guard behavior unchanged otherwise - scheduler.ts: funding-collector job (*/5, no-op unless HOSTED_FUNDING_ENABLED=1) Tests: 10 new DB-backed tests (reservation exactness, double-reservation exclusion, open-batch roll-forward, floor, non-USD skip, commission status untouched, release/payment race, recovery_required escalation, confirmation verification). Suite: 202 passing. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#49) * feat(funding): webhook inbox, funding confirmation wiring, transfer executor (spec §6/§8) Build 3 of the hosted payout funding system (#45). - funding/inbox.ts: StripeWebhookInbox claim/stamp — duplicate deliveries become no-ops at the door; processing failures release the claim so Stripe's redelivery re-runs the (idempotent) handlers - funding/webhook.ts: platform-money event routing, keyed on our own metadata stamps. payment_intent.succeeded re-fetches the live PI (expanded balance transaction) → confirmFundingFromPaymentIntent; payment_failed records the failure for the collector's owned retry; canceled runs the release protocol; charge.refunded/dispute.created on a funding charge freezes the batch as funding_disputed; transfer.reversed maintains the PayoutReversal ledger, derives payout partially_reversed/reversed, and appends compensating CommissionAdjustment entries on full reversal (paid commissions stay immutable history) - funding/executor.ts: transfer executor (5-min job). Transfer intents committed BEFORE transfers.create with frozen keys (fbt:<intentId>); source_transaction-linked transfers draw on the brand's settled charge; per-result short transaction writes Payout(paid) + allocations transferred + commissions paid, webhooks after commit; ambiguous posts past the ~24h idempotency window reconcile by transfer_group listing — never a blind re-POST; commissions mutated since reservation hold the transfer; allocation/principal invariant asserted before money moves - funding/collect.ts: funding_failed retries now CONFIRM the existing PI (per-attempt keys) instead of re-creating under the frozen create key, which would only replay the original creation response - routes/stripe-webhook.ts: funding events route before tenant resolution, flag-independent so late webhooks after a flag flip still land safely - PayoutStatus gains derived partially_reversed/reversed Tests: 11 new (executor happy path with source-transaction assertion, idempotent re-run, mutated-commission hold, invariant freeze, definite 4xx failure, reconcile-by-listing, inbox dedupe, payment_failed CAS, funding_disputed freeze, reversal ledger partial→full). Suite: 213 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(funding): drop unused batch binding in transfer.reversed test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…daily reconciliation (spec §7/§8/§11) (#50) Build 4 of the hosted payout funding system (#45). - funding/interlocks.ts: commission-reversal interlock (finding 5). A commission in a live allocation can't be silently flipped: reserved-batch cancels shrink the principal pre-charge (allocation → released, batch releases outright when emptied); in-flight cancels freeze as 'canceled' and settle as residuals; transfer_pending HOLDS the flip entirely — the executor may be posting that transfer - routes/commissions.ts: admin reverse endpoint runs the interlock; mid-transfer commissions 409 with commission_in_transfer - routes/stripe-webhook.ts (merchant refunds): reverseCommissionsForInvoice runs the interlock; held commissions are surfaced like already-paid rows (claw back via adjustment after settlement) - funding/executor.ts: settlement now closes batches with canceled allocations as settled_with_residual + residualMinor, alerting for an operator disposition (refund / manual payout / credit next batch); invariant checks exclude 'released' allocations (principal shrank with them) and count 'canceled' ones (frozen money) - routes/payouts.ts: POST /payouts/:id/confirm — manual-rail operator confirmation (pending manual → paid + completedAt); this is xispark's launch rail - billing-plan.ts: HostedBillingState mirror (finding 13). mirrorHostedBillingState upserted from subscription webhooks; hasActivePlan upgrades from 'subscription id non-null' to the mirrored status (past_due keeps service, unpaid/paused/canceled lose it, no mirror row grandfathers legacy tenants). New funding batches additionally require active/trialing billing AND no failed/disputed/recovery batch for the tenant - funding/reconcile.ts + scheduler: daily funding-reconcile job (05:30 UTC) — allocation/principal invariants, stuck batches past the 14-day transfer deadline, recovery/disputed attention list, residuals awaiting disposition, reconcile_required intents, Stripe fee telemetry backfill. Verifies and alerts; never moves money Tests: 9 new (interlock shrink/release/residual/hold paths incl. the 409, manual confirm + not-confirmable, reconciliation invariant + stuck detection) + 4 hasActivePlan mirror cases. Suite: 226 passing. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nbook (#51) * feat(funding): lifecycle interlocks, manual confirm, billing mirror, daily reconciliation (spec §7/§8/§11) Build 4 of the hosted payout funding system (#45). - funding/interlocks.ts: commission-reversal interlock (finding 5). A commission in a live allocation can't be silently flipped: reserved-batch cancels shrink the principal pre-charge (allocation → released, batch releases outright when emptied); in-flight cancels freeze as 'canceled' and settle as residuals; transfer_pending HOLDS the flip entirely — the executor may be posting that transfer - routes/commissions.ts: admin reverse endpoint runs the interlock; mid-transfer commissions 409 with commission_in_transfer - routes/stripe-webhook.ts (merchant refunds): reverseCommissionsForInvoice runs the interlock; held commissions are surfaced like already-paid rows (claw back via adjustment after settlement) - funding/executor.ts: settlement now closes batches with canceled allocations as settled_with_residual + residualMinor, alerting for an operator disposition (refund / manual payout / credit next batch); invariant checks exclude 'released' allocations (principal shrank with them) and count 'canceled' ones (frozen money) - routes/payouts.ts: POST /payouts/:id/confirm — manual-rail operator confirmation (pending manual → paid + completedAt); this is xispark's launch rail - billing-plan.ts: HostedBillingState mirror (finding 13). mirrorHostedBillingState upserted from subscription webhooks; hasActivePlan upgrades from 'subscription id non-null' to the mirrored status (past_due keeps service, unpaid/paused/canceled lose it, no mirror row grandfathers legacy tenants). New funding batches additionally require active/trialing billing AND no failed/disputed/recovery batch for the tenant - funding/reconcile.ts + scheduler: daily funding-reconcile job (05:30 UTC) — allocation/principal invariants, stuck batches past the 14-day transfer deadline, recovery/disputed attention list, residuals awaiting disposition, reconcile_required intents, Stripe fee telemetry backfill. Verifies and alerts; never moves money Tests: 9 new (interlock shrink/release/residual/hold paths incl. the 409, manual confirm + not-confirmable, reconciliation invariant + stuck detection) + 4 hasActivePlan mirror cases. Suite: 226 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(funding): authorization gate + Billing funding card + staging runbook (spec §10/§12) Build 5 of 5 for the hosted payout funding system (#45). - routes/funding.ts: the authorization gate. GET /billing/funding (status, terms version, recent batches); POST /billing/funding/setup — Stripe Checkout in SETUP mode, us_bank_account only (ACH-first launch, card counsel-gated), terms version must match current (stale UI can't record acceptance of unseen terms); POST /billing/funding/complete — verifies the LIVE session (tenant match, complete, SetupIntent succeeded, payment method present) before writing the HostedFundingAuthorization row, and re-verifies the round-tripped admin id against the Admin table instead of trusting it into the FK; POST /billing/funding/revoke — stops NEW batches only, in-flight money follows the state machine - portal Billing: Commission funding card — terms checkbox + bank connect (Checkout redirect + return-leg completion), connected state with replace/revoke, recent batch history with human-readable states, clear manual-rail guidance when funding is off or unauthorized - migration: HostedFundingAuthorization.adminId nullable (env-operator setups have no Admin row; FK still validates non-null values) - .env.example: HOSTED_FUNDING_ENABLED documented (default off — the only funding env var) - docs/payout-funding-staging-runbook.md: the §11 staging matrix as an executable runbook — 25 scenarios across happy path, dunning, webhook robustness (replay/tamper/loss), transfer edges (crash mid-post, >24h reconcile, reversals), interlocks, eligibility gates, and reconciliation, plus the founder-approved rollout order (counsel gates the prod flag only; first funded tenant is US/USD, xispark stays manual until Bacs/GBP ships) Tests: 4 new (setup requires subscription + current terms; complete verifies tenant/SetupIntent and refuses forged sessions; revoke idempotency). Suite: 228 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The 20260618 migration renamed Campaign→Program, but three raw string
references to the old table name survived (TABLES constants were
updated; these bypassed them):
- apps/router/src/server.ts: db('Campaign') in the destination
fallback — PRODUCTION BREAKER. Any share link without a per-link
destinationUrl override (i.e. every link inheriting the program's
destination — the normal case) 500'd at the edge once the rename
migration reached the production DB.
- apps/api/src/campaign-end-notifications.ts: raw SQL update "Campaign"
— the daily campaign-end reminder sweep has been throwing since the
rename applied.
- apps/portal/src/pages/AdminExport.tsx: export page offered a
'Campaign' table that no longer exists.
Router destination fallback now uses TABLES.Program so the next rename
can't silently miss it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ke redirects (#53) The router's Click insert predates multi-tenancy and never set tenantId; it survived on the single-tenant column default until the migration that dropped it reached the production DB. Real links then 500'd at the insert (unknown keys still 404'd fine, which is what made it look 'partially working'). Clicks inherit the tenant from the Link row — the router runs on the privileged pool, so nothing else stamps it. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.