Skip to content

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

Open
keithfawcett wants to merge 131 commits intopre-ultrareview-2from
main
Open

Re-review: admin personas + install wizard + mail UI + Network carve#9
keithfawcett wants to merge 131 commits intopre-ultrareview-2from
main

Conversation

@keithfawcett
Copy link
Copy Markdown
Contributor

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

Scope

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

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

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

Areas of highest concern for review

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

Not for merge.

Keith Fawcett and others added 30 commits April 24, 2026 10:00
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>
Keith Fawcett and others added 15 commits April 29, 2026 22:40
The app-role migration is one-shot — once knex_migrations records it as
applied, changes to OPENPARTNER_APP_DB_PASSWORD never propagate into
Postgres. Rotating the password (or setting it for the first time after
the migration already ran without it) leaves the role's stored password
out of sync with the env, and the app pool fails authentication on
every request.

Add an idempotent ensure-app-role script that the entrypoint runs after
migrations on every boot. Creates the role if missing, syncs the
password if present, re-applies grants on tables that exist. Skipped
when OPENPARTNER_APP_DB_PASSWORD is unset to preserve self-host
behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Network protocol describes a vendor heartbeat (POST /vendors/me/
heartbeat) that pings partner count + last-event timestamp every 24h.
The handler exists on the Network side but no caller had ever shipped,
so the Network's stored partnerCount was frozen at registration time
(0), and the brand admin Network page always rendered "0 active
partners" regardless of local roster size.

Three pieces:

  * sendHeartbeat() in network-client.ts — counts non-revoked Partners
    and the most-recent Event timestamp, posts to the Network.
  * network-heartbeat scheduler entry, hourly @ :07. Hourly is overkill
    for the Network's prune logic but keeps the live data feeling
    responsive across the cluster.
  * /admin/network/me proxy now overrides the remote partnerCount with
    the local count. Means a brand admin who just added a partner sees
    the right number instantly, without waiting on the next heartbeat
    tick. The Network's stored value still gets reconciled by the
    scheduler so its own prune logic stays accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The invite dialog only confirmed the email was sent — if the creator's
inbox dropped it (spam, paused workflows, typo'd address) the brand had
no recourse. Surface the deeplink in the success state with a Copy
button so the brand can DM it directly when needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The marketing docs tell users 'npm install @openpartner/sdk', but the
package was never actually pushed to the registry — first attempt
returns 404. Wire up a publish workflow that fires on the same v* tag
trigger as the docker push, so a tagged release ships both the docker
images and any SDK version bump in one go. Idempotent: skips when the
local SDK version is already on npm.

Provenance is enabled (--provenance + id-token: write) so the published
tarball carries a signed Sigstore attestation tying it back to this
workflow + commit. Defends against a leaked NPM_TOKEN: an attacker who
publishes from elsewhere can't forge the attestation.

Requires:
  * NPM_TOKEN secret set in GitHub repo settings (automation token from
    npmjs.com)
  * @OpenPartner scope claimed on npm by an account the token belongs to

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
npm now actively recommends Trusted Publishing over long-lived
automation tokens — the publish credential is minted per-run from
GitHub's OIDC token instead of a static secret. No NPM_TOKEN to leak,
no rotation, and the Sigstore provenance attestation comes for free
from the same id-token.

Trade: requires one-time configuration of a trusted publisher on the
package settings page at npmjs.com (provider: GitHub Actions, repo +
workflow filename) before the first publish succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bare <Link to="/links?..."> built absolute paths that bypassed the
/t/<slug> prefix in multi-tenant mode, so action links on the Partners
admin and the partner cards on the Links page jumped out of the
workspace and 404'd. The Dashboard already had a private TenantLink
helper; lift it to a shared module and route every brand-workspace
link through it.

Audited every <Link> in the brand-side tree (admin + partner pages)
and swapped any with absolute paths that target tenant-scoped routes.
Top-level routes (Landing, Signin, Signup, Workspaces) and the
creator portal subtree intentionally untouched — those live outside
the tenant scope.

Tested: typecheck clean, no missing imports, manual sweep of remaining
<Link> uses confirms each is intentionally top-level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Partners action row had inconsistent UX: Links and Payouts
navigated to dedicated pages, while Programs and Coupons popped
inline panels above the table. Same row, two different mental models —
read as randomness rather than convention.

Move Programs and Coupons to /admin/partners/:id/programs and
/admin/partners/:id/coupons. Now every action in the row navigates,
URLs are linkable + DM-able, and the special "what mode is this row
in" beat goes away. Page bodies reuse the dialog logic verbatim, with
a back-link to the partner list and a partner name fetch via the
existing GET /partners/:id endpoint.

AdminPartners.tsx loses ~200 lines of dialog state + components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brand logo (openpartner):
  * Tenant.logoUrl column + migration
  * POST /uploads/logo (admin auth, multipart-style raw binary body,
    image/* allowlist, 2 MB cap)
  * AdminSettings: file picker + preview replaces text-only brand info
  * Sidebar header swaps Logo() for the uploaded image when present

Creator avatar (network side already had Creator.avatarUrl):
  * Wired the avatar through the existing /api/creator-api proxy on
    openpartner with a raw-body bypass for image content-types
  * CreatorMyProfile: dropped the "paste a URL" field, replaced with
    file picker that posts to the Network upload endpoint
  * CreatorShell sidebar chip swaps the letter avatar for the image
    when present

Storage backend pluggable via OPENPARTNER_STORAGE_KIND:
  * fs (default) — local dir served at /uploads/* via express.static.
    Fine for self-hosters; ephemeral on App Platform unless you mount
    a volume.
  * s3 — any S3-compatible bucket. AWS SDK lazy-imported so self-host
    installs that don't set STORAGE_KIND=s3 don't pay the dep cost at
    runtime. Same env var shape on the Network side under NETWORK_*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real fixes:
  * crypto.ts: pass authTagLength: 16 to createDecipheriv so Node won't
    accept short GCM tags on decrypt (gcm-no-tag-length).
  * Dockerfiles (api, portal, router): add USER node / USER nginx so the
    runtime container doesn't run as root. Includes the chown -R needed
    on the portal nginx paths so cache/log/pid writes work as the
    unprivileged user. Listening ports are all >1024 already so no
    CAP_NET_BIND_SERVICE shenanigans.
  * creator-portal proxy: pin response Content-Type to application/json
    even when upstream sends something else, so a future Network
    response that returns html/plain can't end up XSS'd into a
    browser-rendered page on app.openpartner.dev.

Documented false positives (nosemgrep + comment):
  * stripe-webhook runInTenant: tenantId comes from a DB lookup of our
    own metadata, single-quotes are escaped, and Postgres SET LOCAL
    doesn't accept bind params — no SQL injection.
  * db/ssl.ts: rejectUnauthorized=false for sslmode=require is the
    documented escape hatch for managed Postgres CAs that aren't in
    Node's trust store; operators wanting full verify use
    sslmode=verify-ca / verify-full.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dark theme matching the portal (bg #0b0d10, accent #2dd4bf), cream
logo on the left, wordmark + tagline on the right, MIT footer. Built
at 1280x640 — the ratio GitHub, Twitter, Slack, and Discord all
expect. SVG so it can be re-rendered cleanly when copy or accent
colors change; the social-preview slot itself takes a PNG export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added Buy Me a Coffee funding option for keithf.
Earlier draft used the app-portal teal (#2dd4bf) and a generic
"open-source partner attribution" tagline. The marketing site at
openpartner.dev leads with a different positioning — emerald accent
(#16a34a), eyebrow pill, "Launch a partner program. Grow it with the
Network." headline. Bring the OG card in line so a click from
Twitter/Slack/GitHub previews lands on a visually consistent first
impression with the homepage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pricing table was a stale draft from before the Flex/Revshare/
Network-add-on structure landed. Sync to what openpartner.dev/pricing
and the Stripe products (per setup-stripe.mjs) actually charge:

  * Self-hosted: Free
  * Self-hosted + Network: $29/mo + 3% on Network-originated payouts
  * Hosted — Flex: $49/mo + 1.5% of attributed GMV
  * Hosted — Revshare: 3% of attributed GMV, no monthly
  * Enterprise: Custom

Also updated the OPENPARTNER_MODE one-liner in the implementation
status block to call out the Flex / Revshare names so the env value
isn't disconnected from the marketing labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CODEOWNERS:
  * Default reviewer for everything.
  * Hard-block paths: build/CI config, the crypto/auth/db trio, the
    migrations directory, and the network-protocol contract that the
    federation builds against. Touching any of these requires explicit
    maintainer approval — same rule as the branch-protection
    "Require review from Code Owners" toggle relies on.

SECURITY.md:
  * Private vulnerability reporting URL (GitHub Security Advisories)
    plus an email fallback.
  * Acknowledgement and fix SLAs (48 hours, 7 days for critical).
  * Scope + out-of-scope so reporters know what to send and what to
    skip — keeps inbox noise out of the queue.
  * Reserves a hall-of-fame slot for opt-in reporter credit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-advanced-security
Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Copy link
Copy Markdown

@github-advanced-security github-advanced-security AI left a comment

Choose a reason for hiding this comment

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

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Keith Fawcett and others added 4 commits April 30, 2026 13:15
The pill repeated info that's already implicit in the headline + body
("partner program software" / "built-in network"). Removing it lets the
two-line headline breathe and improves the at-a-glance read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three open Dependabot alerts cleared:
  * vite <= 6.4.1 path-traversal in optimized-deps .map handling
    (GHSA-4w7w-66w2-5vf9), in apps/portal direct dep + transitively
    via vitest's bundled vite
  * esbuild <= 0.24.2 dev-server cross-origin request issue
    (GHSA-67mh-4wv8-2f99), pulled in by vitest 1's bundled vite 5

Bumps:
  * apps/portal: vite ^5.4.8 -> ^6.4.2
  * workspace: vitest ^1.6.0 -> ^3.2.4 (root + 5 packages)

Both are dev-only — production runtime is the prebuilt portal/dist
served by nginx, so the dev-server vulnerabilities never reach a
deployed instance. Closing the alerts keeps the public repo's
security tab clean and stops Dependabot from filing new PRs.

Verified: pnpm typecheck clean across the workspace; sdk + portal +
router tests pass on vitest 3.2.4. api tests need a Postgres connection
so they exercise on CI.

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

  1. Flat-config ESLint had no globals declared, so 'no-undef' fired
     on every console / process / Buffer reference (28 errors). Add
     the 'globals' package and wire node globals everywhere, plus
     browser globals on portal + sdk.

  2. Five real but trivial fixes the new lint pass surfaced:
       - mailer.ts safeDisplayName: extract regex to const + targeted
         no-control-regex disable so the RFC 5322 control-char strip
         stays as-is.
       - routes/signup.ts: adminId is never reassigned -> const.
       - tenancy.ts: keep the Express Request augmentation under a
         narrow no-namespace disable; the canonical shape requires it.
       - campaign-end-notifications.ts: drop unused Knex import.
       - CreatorShell.tsx: drop unused useQuery import.

CI's `pnpm lint` step now actually validates rather than always
failing — important before the repo goes public and outside PRs start
hitting the lint gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  * uploads.ts: req.header('content-type') instead of
    req.headers['content-type'] — closes the type-confusion alert
    (a client could otherwise smuggle an array as a single header).
  * Global express-rate-limit: 600 req / 5 min per IP, with /health,
    /metrics, /webhooks/* (Stripe retries), and /uploads/* exempted.
    Closes the 73 missing-rate-limiting alerts and gives self-hosters
    baseline protection without an edge layer in front.
  * ci.yml: explicit `permissions: contents: read` at the workflow
    level. Closes the missing-workflow-permissions alert. Docker job
    keeps its own packages:write permission.
  * cookieParser: comment block documenting the SameSite=Lax + CORS
    allowlist + bearer-key isolation that mitigates CSRF, since CodeQL
    can't follow cookie SameSite settings statically.

Remaining open alert is js/clear-text-logging on the secrets_probe — a
false positive (we log .length, never the value). Will dismiss via
the security UI with that rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
// hazard if a client sends multiple Content-Type headers.
let validated;
try {
validated = validateImageUpload(req.header('content-type'), req.body?.length ?? 0);
Comment thread apps/api/src/server.ts Dismissed
Keith Fawcett and others added 5 commits April 30, 2026 14:48
The hosted multi-tenant deployment now lets each tenant pick a tier at
signup (Flex / Revshare / Enterprise) instead of the deployment-wide
OPENPARTNER_MODE env. Selfhost installs keep the global env behavior.

Schema:
  * Tenant.billingPlan ('flex' | 'revshare' | 'enterprise' | null) with
    a CHECK constraint and a backfill of stripeCustomerId /
    stripeSubscriptionId from the legacy Config keys (billing.ts wrote
    Config; account-deletion.ts read Tenant — silently divergent).
  * Tenant.trialEndsAt for the 14-day trial high-water mark.

Resolver (billing-plan.ts):
  * getTenantBillingState(db, tenantId) returns plan + mode + trial
    + Stripe pointers in one shape every caller can switch on.
  * planToMode collapses the plan enum to the legacy
    selfhost/flat/revshare mode for downstream code.
  * priceIdsForPlan + TRIAL_DAYS as the single source of truth.

Signup:
  * Schema accepts optional plan enum ('flex' | 'revshare' | 'enterprise').
    Stamped onto Tenant.billingPlan; recovery path also refreshes it.
  * Frontend reads ?plan= from the URL (set by marketing CTAs) and
    sends it through. Renders a "Plan: Flex" banner above the form so
    the user knows what they're committing to.

Billing route rewrite:
  * Reads per-tenant plan instead of global mode for status / checkout
    / portal / report-usage.
  * Checkout uses priceIdsForPlan + trial_period_days=14 +
    payment_method_collection: 'if_required' so users start the trial
    without a card. Stripe cancels automatically if no card by trial
    end (trial_settings.end_behavior).
  * Portal endpoint now works for revshare too (was flat-only); only
    selfhost + enterprise are excluded.
  * New /billing/invoices returns the last 12 invoices for the Admin
    Billing page (full detail still in the Stripe Portal).
  * persistMerchantSubscription refactored to a patch object so
    "leave alone" vs "set null" is unambiguous on subscription.deleted.
  * inferPlanFromPriceIds detects plan switches via the Stripe Portal.

Webhook (stripe-webhook.ts):
  * checkout.session.completed: pulls trial_end off the subscription
    and stamps it on Tenant.
  * customer.subscription.updated: detects plan switches via Portal,
    updates Tenant.billingPlan + trialEndsAt to match.
  * customer.subscription.deleted: clears stripeSubscriptionId so the
    admin can re-subscribe.

Usage-billing:
  * reportUsageToStripe reads tenant state instead of getMode() so
    per-tenant plan controls the meter routing.
  * Falls back to the legacy Config customer-id during the rollout
    in case any tenant was never backfilled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend that closes the loop on the per-tenant billing build:

  * /admin/billing route (Settings → Billing in the sidebar). Three
    rendering modes:
      - selfhost: explanatory note, no controls
      - enterprise: out-of-band notice, no Checkout
      - flex/revshare: trial countdown, "Activate trial" CTA when no
        sub, "Manage in Stripe Portal" + invoice list once subscribed
    Stripe Checkout opens with the tenant's plan + 14-day trial,
    no card required to start.
  * "Activate your 14-day free trial" step in the brand-onboarding
    card on the dashboard. Links to /admin/billing.
  * /admin/onboarding-status response gains billingReady (true for
    selfhost / enterprise / active sub). The 'Getting started' card
    only hides once billing is also handled.

The Stripe Customer Portal handles the rest (payment-method updates,
plan switches, invoice history, cancellation) — webhook handlers we
shipped earlier sync any of those changes back to Tenant.billingPlan
and Tenant.stripeSubscriptionId so the dashboard always reflects
current Stripe state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trial-loop hole closed:
  * New Tenant.firstTrialActivatedAt — stamped by the
    checkout.session.completed webhook the FIRST time a sub with a
    trial completes, never cleared. Migration backfills any tenant
    with a current trialEndsAt to (trialEndsAt - 14 days).
  * /billing/checkout reads hasUsedTrial from the resolver. If true,
    the Checkout omits trial_period_days and switches
    payment_method_collection to 'always' — the user must add a card
    up front to resubscribe.
  * Billing UI gains three CTA states:
      - "Activate 14-day free trial" (never trialed)
      - "Subscribe (card required)" (trial used; subscription cancelled)
      - "Re-subscribe (card required)" with a red banner for the
        explicit "trial expired without subscription" state.

Soft 402 gate (apps/api/src/middleware/trial-gate.ts):
  * Returns 402 Payment Required on a small allowlist of write
    endpoints when the tenant is in the trial-expired-without-sub
    state: POST /campaigns, POST /partners, POST /partners/:id/coupons,
    POST /partners/:id/campaigns, POST /import/partners-csv, POST
    /admin/network/offerings.
  * Deliberately leaves open: every read, SDK callbacks (identify,
    events), click ingestion, /coupons/redeem, Stripe webhook, all
    /billing/* routes (so they can resubscribe), all auth routes.
  * Mounted right after tenantMiddleware so it has tenant scope.
  * Returns { error: 'trial_expired', detail, plan } so the UI can
    surface a "your trial ended" banner.

PostHog product analytics (matches studio-website snippet):
  * apps/portal/src/posthog.ts side-effecting module that runs the
    standard array-stub bootstrap when VITE_POSTHOG_KEY is set,
    person_profiles: 'identified_only'. Imported once from main.tsx.
  * Marketing layout gains the same snippet under PUBLIC_POSTHOG_KEY.
    Both default host to https://us.i.posthog.com; both are no-op
    when the key is unset (graceful for self-hosters / local dev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /account/delete 500'd with 'column "amountCents" does not exist'.
checkPendingObligations had three pre-existing bugs from when it was
first written against a planned schema that never landed:

  * Commission column: amountCents -> amount (the actual decimal(14,2)
    column from the initial schema migration). Renamed the field and
    its consumer to unpaidCommissionAmount to stop misleading future
    readers about units.
  * Commission status: 'pending' -> 'accrued'. The Commission status
    enum is accrued | approved | paid | reversed; 'pending' was never
    a valid value. Unpaid = accrued + approved (paid + reversed are
    settled).
  * Payout status: dropped 'processing' (not in the PayoutStatus
    enum, which is pending | paid | failed). Only 'pending' is mid-
    flight.

Account deletion never worked end to end because of these — the
endpoint always 500'd on the obligations check before it could run
any of the actual delete logic.

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

1. Picking a plan inline on /admin/billing was a stub — clicking Flex
   or Revshare just rendered an error redirecting to openpartner.dev/
   pricing, even though the user was already authenticated.
     * New backend endpoint POST /billing/plan that stamps Tenant.
       billingPlan when null. Refuses to overwrite an existing plan
       (those go through Stripe Customer Portal so the subscription
       price IDs change in lockstep).
     * Frontend chains: pick -> set plan -> auto-trigger Checkout in
       one click. PlanPicker accepts a `disabled` prop so it goes
       wait-cursor + 0.6 opacity during the transition.

2. PostHog wasn't firing despite the env var being set. Two suspects:
     * The TS rewrite of the array-stub bootstrap was a re-implementation
       of a snippet that has subtle function-shape requirements
       (arguments capture, push semantics). Replaced with the verbatim
       PostHog snippet injected as <script> via document.head — same
       string studio-website ships, just wrapped in a tiny Vite-aware
       loader.
     * .do/app.yaml didn't declare VITE_POSTHOG_KEY / VITE_POSTHOG_HOST
       at all, so even if set in the DO UI they may not have flowed
       into the build cleanly. Now declared with scope: BUILD_TIME so
       Vite inlines them into the bundle (Astro side gets the same
       treatment in the marketing repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
const HOST = (import.meta.env.VITE_POSTHOG_HOST as string | undefined) ?? 'https://us.i.posthog.com';

if (KEY && typeof document !== 'undefined') {
const snippet = `!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]);t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(/\\/$/,"")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;void 0!==a?u=e[a]=[]:a="posthog";u.analytics=u;u.init=function(i,s,a){e._i.push([i,s,a])};u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e};u.people=u.people||[];g(u,"capture");g(u,"register");g(u,"register_once");g(u,"ready");g(u,"set_config");g(u,"get_config");g(u,"get_property");g(u,"get_distinct_id");g(u,"toString");g(u,"opt_in_capturing");g(u,"opt_out_capturing");g(u,"has_opted_in_capturing");g(u,"has_opted_out_capturing");g(u,"clear_opt_in_out_capturing");g(u,"startSessionRecording");g(u,"stopSessionRecording");g(u,"sessionRecordingStarted");g(u,"getActiveMatchingSurveys");g(u,"getSurveys");g(u,"getNextSurveyStep");g(u,"onFeatureFlags");g(u,"onSessionId");g(u,"getSessionId");g(u,"identify");g(u,"setPersonProperties");g(u,"group");g(u,"getGroups");g(u,"setGroupProperties");g(u,"reloadFeatureFlags");u._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);posthog.init(${JSON.stringify(KEY)},{api_host:${JSON.stringify(HOST)},person_profiles:'identified_only'});`;
const HOST = (import.meta.env.VITE_POSTHOG_HOST as string | undefined) ?? 'https://us.i.posthog.com';

if (KEY && typeof document !== 'undefined') {
const snippet = `!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]);t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(/\\/$/,"")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;void 0!==a?u=e[a]=[]:a="posthog";u.analytics=u;u.init=function(i,s,a){e._i.push([i,s,a])};u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e};u.people=u.people||[];g(u,"capture");g(u,"register");g(u,"register_once");g(u,"ready");g(u,"set_config");g(u,"get_config");g(u,"get_property");g(u,"get_distinct_id");g(u,"toString");g(u,"opt_in_capturing");g(u,"opt_out_capturing");g(u,"has_opted_in_capturing");g(u,"has_opted_out_capturing");g(u,"clear_opt_in_out_capturing");g(u,"startSessionRecording");g(u,"stopSessionRecording");g(u,"sessionRecordingStarted");g(u,"getActiveMatchingSurveys");g(u,"getSurveys");g(u,"getNextSurveyStep");g(u,"onFeatureFlags");g(u,"onSessionId");g(u,"getSessionId");g(u,"identify");g(u,"setPersonProperties");g(u,"group");g(u,"getGroups");g(u,"setGroupProperties");g(u,"reloadFeatureFlags");u._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);posthog.init(${JSON.stringify(KEY)},{api_host:${JSON.stringify(HOST)},person_profiles:'identified_only'});`;
Keith Fawcett and others added 2 commits April 30, 2026 16:06
Two adjacent bugs in resolveTenantId, both silently dropping events
that should have updated Tenant.stripeSubscriptionId / billingPlan /
trialEndsAt:

  * checkout.session.completed only checked client_reference_id (the
    cref from a Rewardful-style merchant→customer checkout). Our
    merchant-subscription Checkout doesn't set client_reference_id,
    so the resolver returned null and the event was dropped — meaning
    the tenant's Stripe subscription was never persisted, and the
    Billing page kept showing "Activate trial" after the user had
    already completed Checkout.
  * customer.subscription.* (created/updated/deleted) only checked the
    Identity → Click chain, which doesn't include merchant-self-
    subscription Customers (those are created by us via
    stripe.customers.create with metadata.openpartner_tenant_id, never
    via attribution).

Fixes:
  * checkout.session.completed: prefer session.metadata.openpartner_
    tenant_id (which we stamp at create time in /billing/checkout).
  * customer.* events: try Tenant.stripeCustomerId first (constant-
    time indexed lookup, no Stripe API roundtrip), fall back to the
    Identity-based Rewardful path.

Recovery for the user's currently-stranded tenant: resend the missed
checkout.session.completed event from Stripe Dashboard → Developers →
Webhooks → recent deliveries → Resend, OR I can add a one-shot
/billing/sync endpoint that pulls latest state from Stripe directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the only way to switch between brand and creator signup was
a small text link buried below the form, and on the brand side that
link didn't even exist — landing on /signup with no way to discover
that creators have their own signup flow.

  * New SignupTabs component in auth/Shared.tsx renders a pill-style
    [Brand | Creator] strip just above the form on both signup pages.
    Active tab in the green accent (theme.accent on theme.accentInk),
    inactive in muted text. Clicking the inactive tab navigates to the
    other signup route, preserving ?plan= so a brand-side query
    survives the toggle (e.g. user lands at /signup?plan=flex from
    pricing CTA, taps Creator, taps back, plan banner is still there).
  * Submit button text now reflects what the form actually creates:
      brand   -> "Create brand account" / "Creating brand…"
      creator -> "Become a creator" / "Creating creator…"
  * Dropped the redundant "Are you a brand?" / "Are you a creator?"
    cross-link footers since the tabs handle that visibly.
  * Brand "Already have a program?" link routes to /signin instead of
    the marketing landing — the user clearly wants in, not back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants