Re-review: admin personas + install wizard + mail UI + Network carve#9
Open
keithfawcett wants to merge 174 commits intopre-ultrareview-2from
Open
Re-review: admin personas + install wizard + mail UI + Network carve#9keithfawcett wants to merge 174 commits intopre-ultrareview-2from
keithfawcett wants to merge 174 commits intopre-ultrareview-2from
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 offering detail page omitted attribution data — discover cards rendered "Last click · 60d window" via OfferingChips, but the dedicated detail page only carried Cookie window / Payouts / Holdback / Duration. Creators landing on the detail page lost visibility into the most consequential terms (which click model they're competing under, how long they have to convert). Adds three new LabeledChips with hover-help: Attribution, Attribution window, Recurring. Falls back gracefully when fields aren't snapshotted (legacy offerings) — chip omitted rather than "Unknown". If your offering is missing these chips after deploy, the bound Campaign predates the snapshot of these fields — re-publish to refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brand admins could create campaigns with start/end dates but had
no UI to end an existing one — endsAt was set-on-create only. Add:
- "End now" per-row button (with confirm). Sets endsAt to the
current time. Existing share-links keep redirecting (router
doesn't gate on endsAt — that would break creators' embeds);
only new commission accrual stops, so safe to fire.
- "Edit dates" per-row button. Opens an inline form with both
startsAt + endsAt datetime pickers. Useful for scheduling a
campaign to run later, extending an end date past the original,
or marking a campaign Ended at a specific past time.
Form scope is intentionally narrow — commission rate / attribution
window / model edits are NOT exposed here for the same reason the
offering edit form pulled them: there's no per-partnership snapshot
of those fields, so editing would silently re-price live conversions
under partnerships that were quoted the original terms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Republish only flipped the published flag — it never re-snapshotted the offering's terms. So offerings created before the attribution snapshot fields existed (or before a Campaign edit) stayed stuck with whatever they were created with, and creator-facing chips silently disappeared. Each offering card now loads its bound Campaign and exposes a "Refresh from campaign" button. Click → re-snaps the structured fields (attributionModel, attributionWindowDays, commissionType, commissionValue, recurring, payoutHoldbackDays, campaignEndsAt) while preserving the brand-editable ones (commissionDescription, cookieWindowDays). PATCH terms only; published flag unchanged. When a snapshot is detectably stale (key fields missing relative to what the Campaign has), a yellow banner surfaces on the card explaining what creators won't see and pointing at the refresh button. Avoids users wondering why the chip strip is sparse. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffolds the smallest defensible "wire your CRM into OpenPartner"
path so partners can earn commission on B2B/SaaS funnel events
(opportunity_won, trial_converted, lead_qualified, ...) — not just
self-serve signups.
Backend:
- /attribution/events accepts a scoped events:write key in
addition to admin auth (via grantScope). Lets brands hand
HubSpot / Zapier / a custom webhook a key that can fabricate
conversion events for their tenant but can't escalate to full
admin (delete partners, edit payouts, etc).
- GET /api-keys?scope=... returns scoped keys for the admin
list view. Plaintext is never persisted — only returned at
create time.
Portal:
- New page at /admin/integrations: WhyCard explaining the
full-funnel attribution problem, a per-key panel (mint /
show-once / revoke flow), and a sample-curl panel.
- Nav item "CRM integration" under Admin.
Outbound (push attribution back to CRM as a contact field) and
native HubSpot/Salesforce app connectors are explicitly out of
scope for this commit — Zapier covers 95% of the use case at a
fraction of the build cost. Roadmap noted in the docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CRM integration page link was relative (/docs/integrations/crm), which under app.openpartner.dev resolved to a 404 on the app's own domain. Docs live on the marketing site at openpartner.dev — point the link there explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the Network's approval-time snapshot push. New
PartnerCommission sidecar table stores the rate + holdback the
partner was approved under; commission accrual + holdback maturity
prefer it over the live Campaign rule.
Files:
- Migration 20260612 — PartnerCommission table (1:1 sidecar to
Partner). RLS scoped to current tenant; app-role grant.
- POST /partners — accepts new commissionSnapshot field, writes
PartnerCommission row when type + value both present (partial
snapshots fall back rather than mis-pricing).
- attribution.ts — resolveCommissionRule prefers snapshot over
Campaign, falls back per-field.
- commission-auto-approve.ts — coalesce(pc.holdbackDays,
cp.holdbackDays) so the maturity window honors snapshot.
- commissions.ts (admin commission list) — same coalesce so the
"Matures in N days" badges + the disabled-while-holding approve
button reflect the snapshot.
- backfill-commission-snapshots.ts — idempotent helper that
backfills single-campaign partners from current Campaign rule
(multi-campaign partners stay on fallback to avoid silently
misapplying one campaign's rule to another's accruals).
- POST /partners/backfill-commission-snapshots — admin endpoint
that wraps the helper.
- GET /partners/:id/commission-snapshot — surfaces the snapshot
for the upcoming partner-detail UI panel.
Recurring stays on the Campaign rule even with a snapshot —
campaign-level "do we pay on renewals" decision, not a per-
partnership negotiation. Revisit when an actual customer asks.
Backward compatible: partners with no snapshot row keep computing
from Campaign rule (status quo). No behavior change for existing
partners until the backfill endpoint is hit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that PartnerCommission stores per-partner snapshots and the
accrual reads from them, the brand can safely edit commission rate
+ holdback in place without re-pricing existing partnerships. UI
catches up:
- AdminCampaigns: "Edit dates" → "Edit". Form now exposes
commissionRule (type/value/recurring) + holdbackDays under a
"Terms" section behind a banner that explains the snapshot
semantics ("affects new partnerships only; existing keep
snapshotted rate").
- NetworkOfferings: re-add commission summary + cookie window
edits (pulled earlier when there was no snapshot to protect
existing partners). Same banner. Title + description still
free-edit (listing copy, no contract impact).
- AdminPartners: new collapsible Tools card with a "Backfill
commission snapshots" button. Reports scanned / inserted /
skipped counts inline. Idempotent — admins can run repeatedly.
Both edit forms point at Admin → Partners → Tools for the
backfill so partners onboarded before snapshots shipped also gain
grandfathering protection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VIP creators on a higher cut than the campaign default. Backend
already supported it (PartnerCommission row with source='amendment'
took precedence at accrual via resolveCommissionRule); admins just
had to write SQL. Now there's a UI:
- PUT /partners/:id/commission-snapshot — admin override that
upserts a PartnerCommission row with source='amendment'.
Replaces any prior snapshot in full.
- DELETE /partners/:id/commission-snapshot — clears the override;
partner falls back to the live Campaign rule.
- New page at /admin/partners/:id/commission with current
snapshot panel + edit/clear form.
- Commission link on each partner row in AdminPartners.
Past commissions aren't recomputed — only future events accrue at
the new rate. UI calls that out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backfill tool exists for partners that predate the snapshot
federation work — but snapshots ship as part of the same release
that introduces them, so there are no such partners on any live
instance today. Visible UI for a hypothetical legacy state is
just clutter.
Drops:
- ToolsCard from AdminPartners (the collapsible Tools panel +
backfill button)
- The "Run the backfill once if you want this guarantee for
partners onboarded before snapshots shipped" line from the
Campaign + Offering edit-form banners
Keeps the POST /partners/backfill-commission-snapshots endpoint
intact as a quiet escape hatch for any future self-hoster who
upgrades across this commit boundary — small route handler, no
maintenance cost, useful when it's actually needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NavItem's prefix match treated /admin/network as active for any
path under it (Offerings, Requests, Discover creators, Billing),
so Connection stayed highlighted even when the actual active sub-
item was something else.
Two changes:
- Default prefix match now requires a "/" separator after href,
so /admin/network doesn't match /admin/network-foo (defensive).
- New `exact` opt-in on NavItem; passed for /admin/network so it
only highlights on its own page, not on children that have
their own nav entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the missing webhook events that integration platforms (Zapier,
ActivePieces, n8n) reach for first:
- commission.accrued — fired alongside attribution.created when a
Commission row is inserted. Subscribers want this distinct from
attribution.created so they can drive partner-Slack pings,
earnings emails, etc. without parsing the broader attribution
payload.
- partner.created — fired from POST /partners after the row is
inserted. Always fires (admin invite, self-signup, federation).
- partnership.approved — fired from POST /partners only when the
new Partner came via Network federation (metadata.source ===
'openpartner_network'). The event was already declared in the
KNOWN_EVENTS list but never dispatched.
- partner.activated — fired from /auth/magic/verify the first
time an invited partner consumes their magic link. Distinct from
partner.created (which fires at invite time) — this is the "they
accepted + are now promoting" signal welcome sequences want.
WebhookEventType union extended in @openpartner/db; KNOWN_EVENTS
endpoint validation list extended too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two prep changes for Zapier / ActivePieces integrations:
GET /partners now supports:
- ?email=foo@bar.com — exact-match filter, lowercased to match
the canonical storage form. Lets Zapier's "find partner by
email" search action work without scanning + filtering client-
side.
- ?cursor=... + ?limit=N — opaque base64url cursor (createdAt|id
composite key, encoded so callers don't try to construct it),
page sizes 1-200 (default 100). Stable ordering via createdAt
DESC + id DESC tiebreak. Returns { partners, nextCursor }
with nextCursor=null on the last page. Replaces the hardcoded
.limit(500) that blocked any real "fetch all" use case.
- Now also accepts partners:read scoped keys (was admin-only).
POST /webhooks/:id/test — synchronous test ping. Sends a signed
webhook.test envelope to the subscriber URL and returns the
WebhookDelivery row inline. Zapier's connection-setup flow expects
this — lets the admin verify URL reachability + signature parsing
without waiting for a real event to fire and then digging in the
delivery log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes for the same root cause: multi-tenant installs need the
/t/<slug>/ prefix on every API request, and missing it was the
single biggest config foot-gun (just hit when wiring the Zapier app
in prod).
Backend (apps/api/src/auth.ts):
- When requireAuth fires on a route that didn't pass through
tenant resolution (no req.db), return a clean 400 with
error='tenant_slug_missing' + a detail message naming the fix,
instead of throwing a generic 500 with the internal-only
"requireAuth invoked without a tenant-scoped req.db" message.
Same JSON shape as other 4xx errors so client extractors
(api.ts friendlyMessage) surface the detail in the UI.
Portal (admin/Integrations.tsx):
- Render the curl example with the actual full URL the admin
should paste — origin + /t/<slug> when on multi-tenant — so
they copy-paste correctly without thinking.
- Add a callout above the curl block on multi-tenant tenants
explaining that leaving the slug out returns a
tenant_slug_missing 400. Single-tenant installs see neither
the prefix nor the callout, so the message stays clean.
Catches both the audience that wires CRM webhooks via curl AND
the audience setting up the Zapier app (whose auth field had the
same trap; fixed there separately).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-partner commission endpoint already accepted commissions:read scoped keys via grantScope, but the tenant-wide GET /commissions did not. Scoped keys minted for Zapier / ActivePieces / CRM workflows therefore 403'd on the list endpoint that powers their performList sample-data fetch. One-line addition mirrors the per-partner route's middleware. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CRM Integration page documented that minted keys carry events:write + partners:write + partners:read + commissions:read (matching what Zapier / ActivePieces / CRM workflows need), but the create mutation only sent events:write — a doc/code mismatch that surfaced the moment the Zapier app's performList tried to read /commissions and 403'd with forbidden_scope. Mint the full union now. The broader scopes don't materially increase risk — none of them touch billing, payouts, or admin surfaces (those have no scope grants anywhere). One key per integration still gives clean revocation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Devs minting an integration key had to trust the docs about what
the key carries; now they can see the scopes inline. Sourced from
the same SCOPE_DESCRIPTIONS constant the create mutation reads,
so the displayed list can never drift from what's actually minted.
Collapsed by default ("Generated keys carry 4 scopes (events:write,
partners:write, partners:read, commissions:read)") to keep the
create flow visually quiet for admins who don't care; expanded
shows one-line descriptions per scope plus the "doesn't touch
billing/payouts/admin" guarantee that bounds the blast radius of
a leak.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brand admins setting up Zapier / ActivePieces / custom webhook
integrations need a way to fire realistic events without seeding
fake conversion data on the brand's DB. Existing webhook.test
ping verified URL reachability + signature parsing but didn't
help test event-type-filtered triggers (Zapier subscribes to
specific events, so a webhook.test fire wouldn't match a
"commission.accrued" trigger config).
Backend:
- sendTest() now takes an optional eventType. Synthetic payload
factories per known event type produce shape-matched bodies
that mirror what the real attribution.ts / payouts.ts /
commissions.ts dispatches send. Fields tagged _synthetic:true
so subscribers can distinguish test fires from real events
if they care.
- POST /webhooks/:id/test now accepts ?event=commission.accrued
(or any of the 9 fireable event types). Bypasses subscription
filtering — the synthetic event goes to the chosen endpoint
regardless of whether it subscribes (it's a testing tool, not
normal dispatch).
Portal (admin/Webhooks):
- Each endpoint card gains a "Send test event" row: dropdown of
the 9 fireable event types + Fire button. Inline result shows
"✓ Delivered (HTTP 200)" or the failure reason.
- KNOWN_EVENTS list updated to match the backend's set
(commission.accrued + partner.* were missing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KeyTab was hardcoding nav('/') after a successful API-key sign-in,
which strips the tenant prefix on multi-tenant deployments and
lands users on the marketing landing page instead of their
tenant's admin dashboard. Reproduced by signing in via API key
at /t/zapier-test/login → bounced to app.openpartner.dev/.
Use useTenantBase() (the same hook the rest of the app uses for
tenant-prefixed paths) so the post-sign-in redirect lands on
/t/<slug>/ when applicable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ys work
Pasting a scoped integration key (the kind minted under Admin →
CRM integration for Zapier / ActivePieces / CRM webhooks) into
the portal's API-key tab succeeded auth but rendered the partner
sidebar with no partner identity — because role='scoped' isn't
'admin', the portal defaulted to partner UI. That's the wrong
mental model: scoped keys are server-to-server credentials, not
portal sign-in.
- GET /auth/whoami now returns 403 scoped_key_not_for_portal
for scoped keys (was returning {role:'scoped'} which the
portal didn't know how to render).
- Login KeyTab catches the new 403 and shows a pointed error:
"scoped integration keys can't sign into the portal — use
your admin magic link or admin API key."
- Tab label "API key" → "Admin / partner key" so the legitimate
use cases are upfront.
- Added "Not for integration keys" line under the form
pointing at the right place to use those.
Doesn't remove the API-key sign-in entirely — it stays for the
real edge cases (admin recovery when SMTP is down, self-host
without email, CLI/automation). Just makes clear which keys work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign out was revoking the server-side session row but not clearing
the cookie in the browser, because Express's res.clearCookie()
only succeeds when the options match the original Set-Cookie's
attributes (path + secure + sameSite + domain). Passing just
{ path: '/' } emits a Set-Cookie the browser sees as a different
cookie (no Secure, no SameSite) so the original op_session
persists. On refresh, the cookie was sent back, resolveSession
filtered it out via revokedAt — but that gap meant the user got a
brief flash of "still logged in" if the session check raced with
the cookie clear, and persistent cookie pollution either way.
Reuse the same sessionCookieOptions() / platformSessionCookieOptions()
factories at clear time so the attributes match exactly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace signout was only revoking op_session — the op_platform_session cookie (cross-workspace identity used by the Workspaces picker) survived. Since the Workspaces page auto- enters when there's exactly one workspace, the next navigation after signout silently bounced the user back in via the still- valid platform session — which manifested to the user as "sign out doesn't work." Confirmed via runtime logs: after a clean signout (op_session cleared correctly), the next request was POST /workspaces/enter returning a freshly-issued op_session, all driven by the unchanged op_platform_session cookie. Fix: kill both in parallel. Users expect "Sign out" to mean "sign me all the way out", not "out of this workspace; back in via platform identity in 200ms." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix called api('/auth/platform-signout', ...) which
auto-prepends the tenant prefix → /api/t/<slug>/auth/platform-
signout. But /auth/platform-signout is mounted BEFORE
tenantMiddleware (it's a cross-workspace endpoint, not tenant-
scoped), so requests with the prefix 404.
Use raw fetch at /api/auth/platform-signout. The platform
session cookie still flows via credentials: 'include'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The WhyCard text still claimed "scoped to events:write only" even after the create mutation was expanded to mint all four integration scopes (events:write + partners:write + partners:read + commissions:read). Misleading on its face — and a real concern for any admin reading the page to evaluate blast radius. Updated copy + the file header docstring to honestly describe what a leak can do (fabricate events + create/revoke partners) and what it can't (billing, payouts, campaigns, admin surfaces). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /webhooks + DELETE /webhooks/:id required admin role with no scope grant, so the scoped key Zapier / ActivePieces use 403'd when their performSubscribe / performUnsubscribe tried to register the trigger callback URL. The integration could authenticate but not subscribe — making instant triggers unusable. Add webhooks:write scope to both routes and include it in the bundled set the CRM Integration page mints. Existing keys minted before this change need to be revoked + re-minted to pick up the new scope (admin → CRM integration → revoke old, generate new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /partners (create) and POST /partners/:id/revoke had the partners:write scope grant; POST /partners/:id/invite (the resend-invite route) did not. Caught by the Zapier app's "Resend Partner Invite" action 403'ing in prod with a scoped key. Conceptually it's all one capability (partners:write covers "manage partner lifecycle") so they should all share the grant. Revoke + invite + create now consistently behave. Reinstate intentionally left admin-only for now (not exposed in any integration's action surface; can add later if needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes surfaced by Postmark suppressing a partner's email (johhn@gmail.com — hard-bounced before, marked inactive) during a Zapier-driven Revoke Partner test: revoke endpoint: Notification email is best-effort. The DB writes (revokedAt, session kill, key kill) already committed before the mail send — a Postmark 422 / SMTP outage / bounce was bubbling as a 500 and making the caller think the revoke didn't happen. Wrap the send in try/catch, log on failure, return notified + notifyError in the response so callers can see the partial outcome. invite endpoint: Mail-send failure here genuinely means the partner can't get the magic link, so the request DID effectively fail — but return 422 mail_send_failed (not 500) with the upstream detail so callers distinguish "partner uncontactable" from a real server error. Postmark suppression, bad address, etc. surface with actionable messages instead of a generic internal_error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 14-day evaluation window now starts at signup so brands can't indefinitely use the system without paying — and Stripe Checkout no longer hands them a *second* trial when they finally subscribe. - Signup stamps trialEndsAt = now+14d and firstTrialActivatedAt = now on Tenant insert (and on slug-recovery resets). - Stripe Checkout drops trial_period_days + trial_settings; payment method is always required. - Stripe webhooks stop overwriting trialEndsAt / firstTrialActivatedAt — those fields are signup-owned now, otherwise sub events would clobber the in-product evaluation deadline with null. - isTrialGateActive() generalises the gate to fire whenever an expired trialEndsAt + no subscription, including no-plan tenants. Legacy tenants without a trialEndsAt are grandfathered. - Add /admin/network/requests/:id/approve to the gated allowlist so brands can't keep accepting Network creator applications past the evaluation window. - Getting Started swaps "Activate your 14-day free trial" for "Subscribe by <date>", with warning/danger tone as the window closes. - Dashboard surfaces a yellow "X partners are waiting for your approval" banner when pending Network requests exist + billing isn't ready, escalating to red once the evaluation period ends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two pre-existing test issues only surfaced after the trial-timing work finished migrating the test DB: 1. Campaign.destinationUrl became NOT NULL in 20260510, but several fixtures still POST /campaigns without it (and the few direct DB inserts did the same). Add destinationUrl + a deepLinkAllowedDomains allowlist so the deep-link override test pattern keeps working. 2. Once integration / regressions / webhooks / stripe-webhook tests actually create Links, those Links survive into the next test file's beforeEach, and a `delete from Partner` blows up on the partner FK. Add a comprehensive child-table cleanup list to partner-invite, admin-invite, network-and-signup, and network-payouts-report so they're hermetic regardless of which suite ran before them. Also add a parallel afterAll truncate to integration so the residue isn't left for the next file at all. Suite is now 99/99 green on a freshly-migrated DB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…the dashboard callNetwork's narrow `if (!res.ok) throw NetworkProxyError(...)` only covered HTTP responses — a bare ECONNREFUSED / DNS / TLS / timeout made fetch throw TypeError or AbortError, which slipped past callers' `catch (e instanceof NetworkProxyError)` guards and 500'd the user-facing request. Caught while testing the trial-timing dashboard with a stale network_membership Config pointing at a port that wasn't listening anymore. Wrap fetch failures in NetworkProxyError(503, 'network_unreachable: <reason>') so the existing resilient catch paths in /admin/onboarding-status and friends gracefully degrade to "Network unreachable, no data this tick" instead of breaking the whole page. Also adds scripts/dev-magic-link.ts — a small helper that mints a fresh admin magic link straight into MagicLinkToken (matching the real prefix/hash format) so the loop "delete admin → /install → inbox-dive for the link" is a one-shot pnpm exec instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Affiliate-network-style postback URLs: a per-partner GET callback to
the partner's own ad tracker (Voluum / Bemob / RedTrack / etc.) with
macro substitution. Distinct from the existing tenant-scoped outbound
webhooks — those belong to the brand and fire signed JSON; postbacks
belong to the partner and fire unsigned GETs the way every affiliate
tracker on the market consumes them.
Architecture:
- New PartnerPostback table, RLS-scoped, with rolling audit counters
(lastFiredAt / lastStatus / successCount / failureCount). No
per-delivery rows — those would balloon at any reasonable partner
volume; counters are enough for "is this firing?".
- Subscription restricted to commission.* events. partner-state
events (partner.created, partnership.approved, etc.) stay
tenant-scoped; sending those to an affiliate tracker doesn't make
sense.
- Fan-out hooked into the existing dispatchEvent path: tenant
webhooks fire first, then partner postbacks for the same event.
Same fire-and-forget contract — neither blocks attribution.
- Macro substitution helper with the standard set:
{click_id} {partner_id} {commission_id} {commission_amount}
{currency} {event_id} {transaction_id} {event_type} {campaign_id}
{payout_id}. Values are URL-encoded; unknown macros stay literal
so partner notices typos.
- Unsigned by design — matches industry convention. Partners that
want verification embed a shared secret in the URL template
themselves.
Surfaces:
- POST/GET/PATCH/DELETE /partners/:id/postbacks (admin OR partner-self).
- Partner portal: /postbacks page with create form, audit counters,
and a macro reference card.
- Admin OR partner-self via requirePartnerOrAdmin('id').
clickId and eventId are now also stamped onto the commission.accrued
dispatch payload (existing tenant subscribers ignore them); they're
how the partner postback macros get their values.
Tests: 5 unit (macro substitution edge cases) + 2 integration
(end-to-end fire-on-commission-accrued via local capturing server,
plus disabled / wrong-event filtering). Browser-tested the partner
flow against a mock tracker; counters tick correctly.
Also extends scripts/dev-magic-link.ts with --partner so signing in
as a partner during dev doesn't require admin-side surgery.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.