From 63f016d4a3eb5947d32b06ebd76295f723669c4e Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Fri, 24 Apr 2026 10:00:13 -0700 Subject: [PATCH 001/131] ci: build @openpartner/db before typecheck 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) --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1daaa6c..7f090d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,12 @@ jobs: - run: pnpm install --frozen-lockfile + # @openpartner/db is consumed via its "types": "dist/index.d.ts" + # field, so downstream typechecks need its build output to exist. + # Also produces the migration bundle the Migrate step runs. + - name: Build @openpartner/db + run: pnpm --filter @openpartner/db build + - name: Migrate run: pnpm migrate From ba67b703174f336836bea4dfc2ba9de0d03d8c65 Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Fri, 24 Apr 2026 10:09:23 -0700 Subject: [PATCH 002/131] fix(safe-fetch): strip IPv6 brackets before IP check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/app.ts | 5 +++++ apps/api/src/network/safe-fetch.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a57941f..c13fb91 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -133,6 +133,11 @@ export function createApp(options: { enableLogger?: boolean } = {}) { app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { req.log?.error({ err }, 'request_failed'); + // Echo message + stack to stderr so CI logs surface 500s that the + // test harness would otherwise swallow. Safe — these are + // unauthenticated server-side error paths; we're logging them + // anyway via pino when the logger's on. + console.error(`[500] ${req.method} ${req.url} ${err?.message}\n${err?.stack ?? ''}`); res.status(500).json({ error: 'internal_error' }); }); diff --git a/apps/api/src/network/safe-fetch.ts b/apps/api/src/network/safe-fetch.ts index ff7575d..87564d3 100644 --- a/apps/api/src/network/safe-fetch.ts +++ b/apps/api/src/network/safe-fetch.ts @@ -52,8 +52,13 @@ async function assertPublicHost(host: string): Promise { // knob a deployed instance can flip accidentally. if (process.env.NODE_ENV === 'test') return; - if (isIP(host) !== 0) { - if (isPrivateAddress(host)) { + // WHATWG URL hostname for IPv6 keeps the [brackets] — strip before + // the isIP / private-address check, otherwise "[::1]" falls through + // to DNS and the guard misses loopback. + const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + + if (isIP(bare) !== 0) { + if (isPrivateAddress(bare)) { throw Object.assign(new Error('private_host_blocked'), { code: 'private_host_blocked' }); } return; From a6db3a6df580418035102cef1655579aa7237782 Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Fri, 24 Apr 2026 10:20:21 -0700 Subject: [PATCH 003/131] ci: quote NETWORK_ENCRYPTION_KEY in workflow env 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. --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f090d5..9005091 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,11 @@ jobs: env: DATABASE_URL: postgres://openpartner:openpartner@localhost:5432/openpartner # A deterministic 32-byte key so federation code paths that require - # NETWORK_ENCRYPTION_KEY don't throw during boot. - NETWORK_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000 - ADMIN_API_KEY: op_ci_0000000000000000000000000000000000000000000000000000000000 + # NETWORK_ENCRYPTION_KEY don't throw during boot. Quoted — YAML + # would otherwise parse 64 zeros as the integer 0 and hand the + # runner the string "0". + NETWORK_ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" + ADMIN_API_KEY: "op_ci_0000000000000000000000000000000000000000000000000000000000" steps: - uses: actions/checkout@v4 From fa6fcb15babaac3f52bb43b85dfb853d3fba3325 Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Fri, 24 Apr 2026 13:39:11 -0700 Subject: [PATCH 004/131] refactor: carve Network + human-auth out of OSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .do/app.yaml | 16 +- .env.example | 30 +- .github/workflows/ci.yml | 6 +- ARCHITECTURE.md | 8 +- README.md | 16 +- apps/api/src/__tests__/integration.test.ts | 3 - apps/api/src/__tests__/magic-link.test.ts | 314 ------- apps/api/src/__tests__/mailer.test.ts | 125 --- apps/api/src/__tests__/network.test.ts | 767 ------------------ apps/api/src/__tests__/safe-fetch.test.ts | 57 -- apps/api/src/app.ts | 12 - apps/api/src/auth-sessions.ts | 149 ---- apps/api/src/auth.ts | 69 +- apps/api/src/email-templates.ts | 150 ---- apps/api/src/mailer.ts | 125 --- apps/api/src/network/crypto.ts | 55 -- apps/api/src/network/federation.ts | 195 ----- apps/api/src/network/safe-fetch.ts | 91 --- apps/api/src/network/validation.ts | 113 --- apps/api/src/routes/auth.ts | 52 +- apps/api/src/routes/magic-link.ts | 468 ----------- apps/api/src/routes/network-creators.ts | 102 --- apps/api/src/routes/network-earnings.ts | 165 ---- apps/api/src/routes/network-offerings.ts | 128 --- apps/api/src/routes/network-requests.ts | 246 ------ apps/api/src/routes/network-vendors.ts | 168 ---- apps/portal/src/App.tsx | 134 +-- apps/portal/src/api.ts | 16 +- apps/portal/src/pages/admin/DevMailbox.tsx | 166 ---- apps/portal/src/pages/auth/Login.tsx | 148 +--- apps/portal/src/pages/auth/MagicLanding.tsx | 97 --- apps/portal/src/pages/auth/Signup.tsx | 160 ---- apps/portal/src/pages/auth/VendorSignup.tsx | 321 -------- .../pages/network/AdminNetworkCreators.tsx | 222 ----- .../src/pages/network/AdminNetworkVendors.tsx | 342 -------- .../src/pages/network/CreatorDirectory.tsx | 250 ------ .../src/pages/network/CreatorProfile.tsx | 216 ----- apps/portal/src/pages/network/Discover.tsx | 295 ------- .../src/pages/network/MyPartnerships.tsx | 346 -------- apps/portal/src/pages/network/MyRequests.tsx | 53 -- .../src/pages/network/OfferingDetail.tsx | 358 -------- .../src/pages/network/VendorOfferings.tsx | 258 ------ .../src/pages/network/VendorRequests.tsx | 140 ---- docker-compose.prod.yml | 9 +- docs/deploy.md | 7 +- docs/email.md | 56 -- packages/db/knexfile.ts | 6 + .../db/migrations/20260425000000_network.ts | 159 ---- .../migrations/20260426000000_promo_codes.ts | 42 - .../migrations/20260428000000_magic_links.ts | 73 -- .../migrations/20260430000000_vendor_email.ts | 50 -- .../20260501000000_drop_network_tables.ts | 34 + packages/db/src/index.ts | 8 - packages/db/src/types.ts | 177 ---- 54 files changed, 110 insertions(+), 7663 deletions(-) delete mode 100644 apps/api/src/__tests__/magic-link.test.ts delete mode 100644 apps/api/src/__tests__/mailer.test.ts delete mode 100644 apps/api/src/__tests__/network.test.ts delete mode 100644 apps/api/src/__tests__/safe-fetch.test.ts delete mode 100644 apps/api/src/auth-sessions.ts delete mode 100644 apps/api/src/email-templates.ts delete mode 100644 apps/api/src/mailer.ts delete mode 100644 apps/api/src/network/crypto.ts delete mode 100644 apps/api/src/network/federation.ts delete mode 100644 apps/api/src/network/safe-fetch.ts delete mode 100644 apps/api/src/network/validation.ts delete mode 100644 apps/api/src/routes/magic-link.ts delete mode 100644 apps/api/src/routes/network-creators.ts delete mode 100644 apps/api/src/routes/network-earnings.ts delete mode 100644 apps/api/src/routes/network-offerings.ts delete mode 100644 apps/api/src/routes/network-requests.ts delete mode 100644 apps/api/src/routes/network-vendors.ts delete mode 100644 apps/portal/src/pages/admin/DevMailbox.tsx delete mode 100644 apps/portal/src/pages/auth/MagicLanding.tsx delete mode 100644 apps/portal/src/pages/auth/Signup.tsx delete mode 100644 apps/portal/src/pages/auth/VendorSignup.tsx delete mode 100644 apps/portal/src/pages/network/AdminNetworkCreators.tsx delete mode 100644 apps/portal/src/pages/network/AdminNetworkVendors.tsx delete mode 100644 apps/portal/src/pages/network/CreatorDirectory.tsx delete mode 100644 apps/portal/src/pages/network/CreatorProfile.tsx delete mode 100644 apps/portal/src/pages/network/Discover.tsx delete mode 100644 apps/portal/src/pages/network/MyPartnerships.tsx delete mode 100644 apps/portal/src/pages/network/MyRequests.tsx delete mode 100644 apps/portal/src/pages/network/OfferingDetail.tsx delete mode 100644 apps/portal/src/pages/network/VendorOfferings.tsx delete mode 100644 apps/portal/src/pages/network/VendorRequests.tsx delete mode 100644 docs/email.md delete mode 100644 packages/db/migrations/20260425000000_network.ts delete mode 100644 packages/db/migrations/20260426000000_promo_codes.ts delete mode 100644 packages/db/migrations/20260428000000_magic_links.ts delete mode 100644 packages/db/migrations/20260430000000_vendor_email.ts create mode 100644 packages/db/migrations/20260501000000_drop_network_tables.ts diff --git a/.do/app.yaml b/.do/app.yaml index 0911ec7..e53533c 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -57,22 +57,9 @@ services: value: ${openpartner-db.DATABASE_URL} - key: OPENPARTNER_MODE value: flat - - key: MAIL_TRANSPORT - value: postmark - - key: POSTMARK_MESSAGE_STREAM - value: outbound - key: ADMIN_API_KEY type: SECRET scope: RUN_TIME - - key: NETWORK_ENCRYPTION_KEY - type: SECRET - scope: RUN_TIME - - key: POSTMARK_SERVER_TOKEN - type: SECRET - scope: RUN_TIME - - key: MAIL_FROM - type: SECRET - scope: RUN_TIME - key: PORTAL_URL type: SECRET scope: RUN_TIME @@ -85,6 +72,9 @@ services: - key: STRIPE_FLAT_PRICE_ID type: SECRET scope: RUN_TIME + - key: METRICS_TOKEN + type: SECRET + scope: RUN_TIME # Click router. Separate component so we can point a marketing apex # (go.yourdomain.com) at it without routing through the portal's diff --git a/.env.example b/.env.example index b57bb21..13cf97a 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,6 @@ PORTAL_PORT=5673 # In production: set to your marketing apex (e.g. .yourdomain.com) COOKIE_DOMAIN=localhost -# Session secret — generate a random 32+ char string for prod -SESSION_SECRET=dev-only-replace-in-prod - # Admin API key for bootstrap (curl/CI). Rotate via POST /api-keys once live. # Generate: node -e "console.log('op_' + require('crypto').randomBytes(24).toString('hex'))" ADMIN_API_KEY=op_devonly_replace_me_with_64_hex_chars_before_any_real_use_xxxx @@ -35,29 +32,14 @@ STRIPE_FLAT_PRICE_ID= VELOCITY_MAX=20 VELOCITY_WINDOW_MS=60000 -# Network federation — AES-256-GCM master key (32 raw bytes, either hex -# (64 chars) or base64 (44 chars)). REQUIRED in production; dev uses a -# weak fallback and logs a warning. -# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -NETWORK_ENCRYPTION_KEY= - -# Router URL override used when federating Network partnerships. If unset, -# we infer from the vendor's instance URL by swapping API port 4601 → 4701. -NETWORK_ROUTER_URL= - -# Mail delivery for magic-link auth. -# dev → writes to the DevMessage table; admins read at /admin/dev-mailbox. -# postmark → POSTs to api.postmarkapp.com/email (requires the vars below). -MAIL_TRANSPORT=dev -MAIL_FROM=OpenPartner -POSTMARK_SERVER_TOKEN= -# Optional — defaults to the "outbound" transactional stream. -POSTMARK_MESSAGE_STREAM=outbound - -# Public portal URL that magic links point at. Defaults to localhost:5673 -# for dev. Set this in prod so emails link to your real hostname. +# Public portal URL. Required in production so CORS has an explicit +# origin allowlist (we refuse to boot with it empty). Defaults to +# localhost:5673 in dev. PORTAL_URL=http://localhost:5673 +# Optional: extra CORS origins beyond PORTAL_URL, comma-separated. +CORS_EXTRA_ORIGINS= + # Optional: bearer token required to scrape /metrics. Leave blank and # /metrics is open (fine on internal networks). Set it when /metrics is # reachable from the public internet. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9005091..035841e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,11 +32,7 @@ jobs: env: DATABASE_URL: postgres://openpartner:openpartner@localhost:5432/openpartner - # A deterministic 32-byte key so federation code paths that require - # NETWORK_ENCRYPTION_KEY don't throw during boot. Quoted — YAML - # would otherwise parse 64 zeros as the integer 0 and hand the - # runner the string "0". - NETWORK_ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" + # Quoted so YAML doesn't parse these as integers or booleans. ADMIN_API_KEY: "op_ci_0000000000000000000000000000000000000000000000000000000000" steps: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e4bed0a..78d3e34 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -86,13 +86,13 @@ One codebase, three behaviors, flipped by `OPENPARTNER_MODE`: The core product — attribution, events, commissions — is identical across modes. Only the billing + payout layer changes. -## The Network +## Federating with an external creator network -OpenPartner has a built-in two-sided marketplace (`NetworkVendor`, `NetworkCreator`, `NetworkOffering`, `NetworkRequest`). Vendors publish offerings (commission terms, assets, description). Creators apply. On acceptance, the vendor instance provisions a `Partner` row via federation — the creator gets a share link like `go.vendor.com/r/`. +OpenPartner OSS is vendor-direct — the admin creates Partner rows, issues share links, and manages their own partner program. A separate hosted service (outside this repo) implements a two-sided creator network: vendors publish offerings, creators apply, and approved partnerships federate back into each vendor's OpenPartner instance as Partner + Link rows. -Federation credentials are scoped API keys (vendor's key for vendor → hosted network, hosted network's key for network → vendor on provisioning). Keys at rest are AES-256-GCM encrypted with `NETWORK_ENCRYPTION_KEY`. +The federation contract is thin: the vendor mints a scoped API key (`partners:write`, `links:write`, `partners:read`, `commissions:read`) and hands it to the network. The network calls the vendor's public API as an authenticated client — no shared schema, no inbound connections into the vendor. Data stays on the vendor's instance. Revoke the key and federation stops. -Self-hosted instances opt in by publishing their instance URL + scoped key. Skipping the Network entirely is supported — the vendor-direct flow (manually create Partners through the admin portal) is the original path. +Not participating in any network is the default. Everything in this repo works standalone. ## Data portability diff --git a/README.md b/README.md index c59e8b6..1930305 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenPartner -**Open-source partner attribution and payouts.** Full attribution from click to revenue, three-tier pricing, your data stays yours. +**Open-source partner attribution and payouts.** Run your own partner program: track every click through to revenue, pay partners out via Stripe Connect, export everything whenever you want. > The open alternative to Dub Partners, Rewardful, and Impact. @@ -8,7 +8,7 @@ Existing partner platforms have two problems: -1. **They stop at the click.** Most tools track who clicked a link. Few reliably track which creator drove which dollar of revenue, 60 days later, across devices, through Safari's cookie blocks. +1. **They stop at the click.** Most tools track who clicked a link. Few reliably track which partner drove which dollar of revenue, 60 days later, across devices, through Safari's cookie blocks. 2. **They lock your data in.** Once two years of attribution history are baked into Impact or Partnerize, switching means starting over. OpenPartner fixes both. @@ -91,12 +91,14 @@ v1. End-to-end attribution, payouts, and export are working; API surface is stab - Commission accrual + review queue (approve / reverse) + Stripe Connect Standard payouts with idempotent transfers - Three deployment modes gated by `OPENPARTNER_MODE`: `selfhost`, `flat` (Stripe subscription), `revshare` (3% platform fee) - Click velocity limits with an admin fraud-review queue that replays skipped attributions on unflag -- Scoped API keys (admin and partner tokens with granular `partners:write`, `links:write`, etc.) + magic-link auth for the portal -- Two-sided OpenPartner Network — vendors publish offerings, creators apply, federation provisions partner records on vendor instances with AES-256-GCM encrypted keys -- Creator-chosen share-link slugs (`go.yourdomain.com/r/`) +- Scoped API keys (`partners:write`, `links:write`, `commissions:read`, …) — the federation contract that lets an external creator-network service provision Partner + Link rows on this instance over REST - Outbound webhooks with HMAC-SHA256 signing and per-event redelivery -- Portable JSON + CSV export per table; full bundle export round-trippable into self-hosted via `POST /import` +- Portable JSON + CSV export per table; full bundle export round-trippable into another instance via `POST /import` - Partner dashboard + admin overview + fraud review + partner funnel analytics in the portal - `@openpartner/sdk` on npm with browser and server entries -- Transactional email via Postmark (magic links, vendor approval, creator signups) +- Prometheus `/metrics` + X-Request-Id correlation - Deployment: DigitalOcean App Platform spec + single-host `docker-compose.prod.yml` behind Caddy + +### Not in this repo + +A creator-discovery / two-sided network layer (vendors publish offerings, creators apply, federation provisions partnerships into vendor instances) is available as a separate hosted service. OpenPartner OSS instances integrate with it via scoped API keys. diff --git a/apps/api/src/__tests__/integration.test.ts b/apps/api/src/__tests__/integration.test.ts index 46d1e2c..3ba1d16 100644 --- a/apps/api/src/__tests__/integration.test.ts +++ b/apps/api/src/__tests__/integration.test.ts @@ -28,9 +28,6 @@ const TABLES_TO_CLEAN = [ TABLES.Link, TABLES.Campaign, TABLES.Payout, - TABLES.Session, - TABLES.MagicLinkToken, - TABLES.DevMessage, TABLES.ApiKey, TABLES.Partner, TABLES.Config, diff --git a/apps/api/src/__tests__/magic-link.test.ts b/apps/api/src/__tests__/magic-link.test.ts deleted file mode 100644 index 128cada..0000000 --- a/apps/api/src/__tests__/magic-link.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Magic-link signup + signin over the live Express + Postgres stack. - * - * We don't send real email — the DevMailer persists to DevMessage, and - * the tests read the stored body to extract the link and consume the - * embedded token. - */ - -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import { TABLES } from '@openpartner/db'; -import { db } from '../db.js'; -import { createApp } from '../app.js'; - -const ADMIN_KEY = 'op_test_magic_admin_0123456789abcdef0123'; -process.env.ADMIN_API_KEY = ADMIN_KEY; -process.env.MAIL_TRANSPORT = 'dev'; -process.env.PORTAL_URL = 'http://localhost:5673'; - -const skipIntegration = !process.env.DATABASE_URL || process.env.INTEGRATION === 'skip'; -const TABLES_TO_CLEAN = [ - TABLES.Session, - TABLES.MagicLinkToken, - TABLES.DevMessage, - TABLES.ApiKey, - TABLES.NetworkCreator, - TABLES.Config, -]; - -const app = createApp({ enableLogger: false }); - -beforeAll(async () => { - if (skipIntegration) return; - await db.raw('select 1'); -}); - -afterAll(async () => { - await db.destroy(); -}); - -beforeEach(async () => { - if (skipIntegration) return; - for (const t of TABLES_TO_CLEAN) await db(t).del(); -}); - -function extractToken(body: string): string { - const match = body.match(/token=([A-Za-z0-9_-]+(?:%3D)*)/); - if (!match) throw new Error(`no token in body: ${body.slice(0, 200)}`); - return decodeURIComponent(match[1]!); -} - -describe.skipIf(skipIntegration)('magic-link auth', () => { - it('full signup → verify → session-backed whoami', async () => { - const signup = await request(app) - .post('/auth/creator/signup') - .send({ email: 'grace@example.com', handle: 'gracie', name: 'Grace Hopper' }); - expect(signup.status).toBe(200); - - // The DevMailer persists; we read the message via the admin endpoint. - const mailbox = await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`); - expect(mailbox.body.messages).toHaveLength(1); - expect(mailbox.body.messages[0].to).toBe('grace@example.com'); - const token = extractToken(mailbox.body.messages[0].body); - - const verify = await request(app).post('/auth/magic/verify').send({ token }); - expect(verify.status).toBe(200); - expect(verify.body.role).toBe('network_creator'); - expect(verify.body.creator.handle).toBe('gracie'); - expect(verify.body.creator.status).toBe('active'); - - // Cookie was set. - const setCookie = verify.headers['set-cookie'] as unknown as string[] | undefined; - expect(setCookie?.[0]).toMatch(/^op_session=/); - const cookie = setCookie![0]!.split(';')[0]!; - - // Session-backed whoami returns the same creator. - const me = await request(app).get('/auth/whoami').set('Cookie', cookie); - expect(me.status).toBe(200); - expect(me.body.role).toBe('network_creator'); - expect(me.body.creator.handle).toBe('gracie'); - }); - - it('signin for an active creator issues a new session', async () => { - // First sign up + verify. - await request(app) - .post('/auth/creator/signup') - .send({ email: 'ada@example.com', handle: 'ada', name: 'Ada' }); - let msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body.messages; - await request(app).post('/auth/magic/verify').send({ token: extractToken(msgs[0].body) }); - - // Now request a signin link as the returning creator. - const signin = await request(app).post('/auth/creator/signin').send({ email: 'ada@example.com' }); - expect(signin.status).toBe(200); - msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body.messages; - expect(msgs[0].subject).toBe('Your OpenPartner sign-in link'); - const verify = await request(app).post('/auth/magic/verify').send({ token: extractToken(msgs[0].body) }); - expect(verify.status).toBe(200); - expect(verify.body.role).toBe('network_creator'); - }); - - it('rejects reused, expired, and unknown tokens', async () => { - await request(app) - .post('/auth/creator/signup') - .send({ email: 'x@example.com', handle: 'xxx', name: 'Xavier' }); - const msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body.messages; - const token = extractToken(msgs[0].body); - - // First consume: ok - const first = await request(app).post('/auth/magic/verify').send({ token }); - expect(first.status).toBe(200); - - // Second consume: already_consumed - const second = await request(app).post('/auth/magic/verify').send({ token }); - expect(second.status).toBe(400); - expect(second.body.error).toBe('already_consumed'); - - // Unknown token: not_found - const unknown = await request(app).post('/auth/magic/verify').send({ token: 'mlt_unknowntoken12345' }); - expect(unknown.status).toBe(400); - expect(unknown.body.error).toBe('not_found'); - }); - - it('signup enforces unique email and handle', async () => { - await request(app) - .post('/auth/creator/signup') - .send({ email: 'dup@example.com', handle: 'dup', name: 'First' }); - - // Same email, different handle. - const dupEmail = await request(app) - .post('/auth/creator/signup') - .send({ email: 'dup@example.com', handle: 'other', name: 'Second' }); - // Only conflicts once the first token is consumed (creator exists). - // Before that, both are pending tokens — signup endpoint only checks - // against existing CREATOR rows, not pending tokens. So consume first: - const msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body.messages; - await request(app).post('/auth/magic/verify').send({ token: extractToken(msgs[msgs.length - 1].body) }); - - // Now both email and handle collide with the new creator row. - const conflict = await request(app) - .post('/auth/creator/signup') - .send({ email: 'dup@example.com', handle: 'fresh', name: 'Second' }); - expect(conflict.status).toBe(409); - - // Consume of the earlier "other" token should also fail now. - void dupEmail; - }); - - it('vendor signup: rejects bad keys, creates pending vendor on verify, activates + signin', async () => { - // Bad instance URL → instance_unreachable. - const badUrl = await request(app) - .post('/auth/vendor/signup') - .send({ - email: 'bad@vendor.com', - name: 'Bad', - slug: `bad-${Date.now()}`, - instanceUrl: 'http://127.0.0.1:1', - instanceKey: 'op_nothing', - }); - expect(badUrl.status).toBe(400); - - // Stand up a real vendor instance (same server). Mint a scoped key - // with only the federation scopes. - const appListen = app.listen(0); - const port = (appListen.address() as import('node:net').AddressInfo).port; - const vendorInstanceUrl = `http://127.0.0.1:${port}`; - const scopedMint = await request(app) - .post('/api-keys/scoped') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ scopes: ['partners:write', 'partners:read', 'links:write', 'commissions:read'] }); - const scopedKey = scopedMint.body.plaintext as string; - - const slug = `good-${Date.now()}`; - const signup = await request(app) - .post('/auth/vendor/signup') - .send({ - email: 'good@vendor.com', - name: 'GoodVendor', - slug, - instanceUrl: vendorInstanceUrl, - instanceKey: scopedKey, - }); - expect(signup.status).toBe(200); - - const mailbox = await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`); - const signupMsg = mailbox.body.messages.find((m: { to: string }) => m.to === 'good@vendor.com'); - expect(signupMsg).toBeDefined(); - const token = extractToken(signupMsg.body); - - const verify = await request(app).post('/auth/magic/verify').send({ token }); - expect(verify.status).toBe(200); - expect(verify.body.role).toBe('network_vendor'); - expect(verify.body.status).toBe('pending'); - // No session cookie yet — vendor is pending admin approval. - expect(verify.headers['set-cookie']).toBeUndefined(); - - // Signin right now should no-op (vendor not active) → no magic link. - await db(TABLES.DevMessage).del(); // clear so we can tell if anything arrives - await request(app).post('/auth/signin').send({ email: 'good@vendor.com' }); - const mailbox2 = await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`); - expect(mailbox2.body.messages.filter((m: { to: string }) => m.to === 'good@vendor.com')).toHaveLength(0); - - // Admin activates. - const vendorId = verify.body.vendor.id; - await request(app).post(`/network/vendors/${vendorId}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - // Signin now DOES issue a link. - await request(app).post('/auth/signin').send({ email: 'good@vendor.com' }); - const mailbox3 = await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`); - const signinMsg = mailbox3.body.messages.find( - (m: { to: string; subject: string }) => m.to === 'good@vendor.com' && m.subject.includes('sign-in'), - ); - expect(signinMsg).toBeDefined(); - const signinToken = extractToken(signinMsg.body); - const signinVerify = await request(app).post('/auth/magic/verify').send({ token: signinToken }); - expect(signinVerify.status).toBe(200); - expect(signinVerify.body.role).toBe('network_vendor'); - const cookie = (signinVerify.headers['set-cookie'] as unknown as string[])[0]!.split(';')[0]!; - - // Session-backed whoami. - const me = await request(app).get('/auth/whoami').set('Cookie', cookie); - expect(me.status).toBe(200); - expect(me.body.role).toBe('network_vendor'); - expect(me.body.vendor.slug).toBe(slug); - - await new Promise((resolve) => appListen.close(() => resolve())); - }); - - it('unified /auth/signin tries creator then vendor', async () => { - // Creator email → creator signin link. - await request(app) - .post('/auth/creator/signup') - .send({ email: 'c@example.com', handle: 'cuser', name: 'Cuser Name' }); - let msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body.messages; - await request(app).post('/auth/magic/verify').send({ token: extractToken(msgs[0].body) }); - - await db(TABLES.DevMessage).del(); - await request(app).post('/auth/signin').send({ email: 'c@example.com' }); - msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body.messages; - expect(msgs).toHaveLength(1); - expect(msgs[0].metadata?.purpose).toBe('creator_signin'); - - // Unknown email → silent (no message). - await db(TABLES.DevMessage).del(); - await request(app).post('/auth/signin').send({ email: 'nobody@example.com' }); - const after = await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`); - expect(after.body.messages).toHaveLength(0); - }); - - it('vendor signin with an email tied to multiple vendors prefers the active one', async () => { - // Regression for ultrareview #20: before the email column existed, - // findVendorByEmail walked magic-link history and returned the most - // recent vendor_signup's slug — wrong when two vendors shared an - // email. Now it reads NetworkVendor.email directly, preferring - // active status over pending. - const { ulid } = await import('ulid'); - const sharedEmail = 'dup@vendor.test'; - // Seed two vendors directly — save the cost of the full signup flow. - const older = ulid(); - const newer = ulid(); - await db(TABLES.NetworkVendor).insert([ - { - id: older, - name: 'Older', - slug: `dup-older-${older}`, - email: sharedEmail, - instanceUrl: 'https://older.example', - instanceKeyCiphertext: 'ct', - instanceKeyPrefix: 'prefix__', - status: 'pending', - createdAt: new Date(Date.now() - 10_000), - }, - { - id: newer, - name: 'Newer', - slug: `dup-newer-${newer}`, - email: sharedEmail, - instanceUrl: 'https://newer.example', - instanceKeyCiphertext: 'ct', - instanceKeyPrefix: 'prefix__', - status: 'active', - createdAt: new Date(), - }, - ]); - - await db(TABLES.DevMessage).del(); - await request(app).post('/auth/signin').send({ email: sharedEmail }); - const msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body - .messages as Array<{ metadata?: Record; body: string }>; - const signinMsg = msgs.find((m) => m.metadata?.purpose === 'vendor_signin'); - expect(signinMsg).toBeDefined(); - - const verify = await request(app).post('/auth/magic/verify').send({ token: extractToken(signinMsg!.body) }); - expect(verify.status).toBe(200); - // The ACTIVE vendor should win, not the older pending one. - expect(verify.body.vendor.id).toBe(newer); - }); - - it('signout clears the session cookie', async () => { - await request(app) - .post('/auth/creator/signup') - .send({ email: 'out@example.com', handle: 'out', name: 'Out' }); - const msgs = (await request(app).get('/dev/mailbox').set('Authorization', `Bearer ${ADMIN_KEY}`)).body.messages; - const verify = await request(app).post('/auth/magic/verify').send({ token: extractToken(msgs[0].body) }); - const cookie = (verify.headers['set-cookie'] as unknown as string[])[0]!.split(';')[0]!; - - const before = await request(app).get('/auth/whoami').set('Cookie', cookie); - expect(before.body.role).toBe('network_creator'); - - await request(app).post('/auth/signout').set('Cookie', cookie); - - const after = await request(app).get('/auth/whoami').set('Cookie', cookie); - expect(after.status).toBe(401); - }); -}); diff --git a/apps/api/src/__tests__/mailer.test.ts b/apps/api/src/__tests__/mailer.test.ts deleted file mode 100644 index 1620585..0000000 --- a/apps/api/src/__tests__/mailer.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * PostmarkMailer unit tests — mock fetch, assert payload shape + error - * handling. DevMailer path is already exercised by the magic-link - * integration suite; we don't duplicate it here. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -describe('PostmarkMailer', () => { - beforeEach(() => { - vi.resetModules(); - }); - afterEach(() => { - vi.unstubAllGlobals(); - delete process.env.MAIL_TRANSPORT; - delete process.env.POSTMARK_SERVER_TOKEN; - delete process.env.MAIL_FROM; - delete process.env.POSTMARK_MESSAGE_STREAM; - }); - - it('POSTs a well-formed payload to the Postmark Email API', async () => { - process.env.MAIL_TRANSPORT = 'postmark'; - process.env.POSTMARK_SERVER_TOKEN = 'test-token'; - process.env.MAIL_FROM = 'OpenPartner '; - process.env.POSTMARK_MESSAGE_STREAM = 'transactional-1'; - - const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => - new Response(JSON.stringify({ ErrorCode: 0, Message: 'OK' }), { status: 200 }), - ); - vi.stubGlobal('fetch', fetchMock); - - const { getMailer, __resetMailerForTests } = await import('../mailer.js'); - __resetMailerForTests(); - - await getMailer().send({ - to: 'grace@example.com', - subject: 'Hi', - text: 'plain', - html: 'rich', - tag: 'creator_signup', - metadata: { handle: 'gracie' }, - }); - - expect(fetchMock).toHaveBeenCalledOnce(); - const call = fetchMock.mock.calls[0]!; - expect(call[0]).toBe('https://api.postmarkapp.com/email'); - - const init = call[1]!; - const headers = init.headers as Record; - expect(headers['x-postmark-server-token']).toBe('test-token'); - expect(headers['content-type']).toBe('application/json'); - - const body = JSON.parse(String(init.body)); - expect(body.From).toBe('OpenPartner '); - expect(body.To).toBe('grace@example.com'); - expect(body.Subject).toBe('Hi'); - expect(body.TextBody).toBe('plain'); - expect(body.HtmlBody).toBe('rich'); - expect(body.Tag).toBe('creator_signup'); - expect(body.MessageStream).toBe('transactional-1'); - expect(body.Metadata).toEqual({ handle: 'gracie' }); - }); - - it('throws on non-2xx HTTP response', async () => { - process.env.MAIL_TRANSPORT = 'postmark'; - process.env.POSTMARK_SERVER_TOKEN = 'bad-token'; - process.env.MAIL_FROM = 'no-reply@example.com'; - - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('unauthorized', { status: 401 })), - ); - - const { getMailer, __resetMailerForTests } = await import('../mailer.js'); - __resetMailerForTests(); - - await expect( - getMailer().send({ to: 'x@example.com', subject: 's', text: 't' }), - ).rejects.toThrow(/postmark send failed: 401/); - }); - - it('throws on Postmark ErrorCode != 0', async () => { - process.env.MAIL_TRANSPORT = 'postmark'; - process.env.POSTMARK_SERVER_TOKEN = 'ok'; - process.env.MAIL_FROM = 'no-reply@example.com'; - - vi.stubGlobal( - 'fetch', - vi.fn(async () => - new Response(JSON.stringify({ ErrorCode: 406, Message: 'recipient suppressed' }), { - status: 200, - }), - ), - ); - - const { getMailer, __resetMailerForTests } = await import('../mailer.js'); - __resetMailerForTests(); - - await expect( - getMailer().send({ to: 'sup@example.com', subject: 's', text: 't' }), - ).rejects.toThrow(/postmark rejected message: 406/); - }); - - it('refuses to start without a token when MAIL_TRANSPORT=postmark', async () => { - process.env.MAIL_TRANSPORT = 'postmark'; - // no POSTMARK_SERVER_TOKEN - process.env.MAIL_FROM = 'no-reply@example.com'; - - const { getMailer, __resetMailerForTests } = await import('../mailer.js'); - __resetMailerForTests(); - - expect(() => getMailer()).toThrow(/POSTMARK_SERVER_TOKEN/); - }); - - it('refuses to start without MAIL_FROM', async () => { - process.env.MAIL_TRANSPORT = 'postmark'; - process.env.POSTMARK_SERVER_TOKEN = 'ok'; - // no MAIL_FROM - - const { getMailer, __resetMailerForTests } = await import('../mailer.js'); - __resetMailerForTests(); - - expect(() => getMailer()).toThrow(/MAIL_FROM/); - }); -}); diff --git a/apps/api/src/__tests__/network.test.ts b/apps/api/src/__tests__/network.test.ts deleted file mode 100644 index 41c1013..0000000 --- a/apps/api/src/__tests__/network.test.ts +++ /dev/null @@ -1,767 +0,0 @@ -/** - * End-to-end Network flow, with a real running Express server standing in - * as the "vendor's OpenPartner instance" that the Network federates to. - * - * Walkthrough: - * 1. Admin registers a NetworkVendor with the local instance URL + admin key. - * 2. Vendor uses the issued vendor API key to publish an Offering tied to - * a real Campaign on their instance. - * 3. Admin creates a NetworkCreator and activates it. - * 4. Creator applies to the Offering. - * 5. Vendor approves → federation POSTs to the same server to create a - * Partner + Link. Partnership row is written with the public share URL. - * 6. We assert the Partner + Link actually exist on the vendor's side. - */ - -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import type { AddressInfo } from 'node:net'; -import request from 'supertest'; -import { ulid } from 'ulid'; -import { TABLES } from '@openpartner/db'; -import { db } from '../db.js'; -import { createApp } from '../app.js'; - -const ADMIN_KEY = 'op_test_network_admin_0123456789abcdef0123'; -process.env.ADMIN_API_KEY = ADMIN_KEY; -process.env.OPENPARTNER_MODE = 'selfhost'; -process.env.NETWORK_ENCRYPTION_KEY = 'a'.repeat(64); // 32 hex bytes - -const skipIntegration = !process.env.DATABASE_URL || process.env.INTEGRATION === 'skip'; -// ApiKey has FKs to NetworkVendor, NetworkCreator, and Partner, so it must -// be cleared BEFORE those parent tables. Similarly Partnership/Request have -// FKs to Offering/NetworkVendor/NetworkCreator. -const TABLES_TO_CLEAN = [ - TABLES.Partnership, - TABLES.PartnershipRequest, - TABLES.Offering, - TABLES.Session, - TABLES.MagicLinkToken, - TABLES.DevMessage, - TABLES.ApiKey, - TABLES.Commission, - TABLES.Attribution, - TABLES.Event, - TABLES.Identity, - TABLES.Click, - TABLES.Link, - TABLES.Campaign, - TABLES.Payout, - TABLES.NetworkVendor, - TABLES.NetworkCreator, - TABLES.Partner, - TABLES.Config, -]; - -const app = createApp({ enableLogger: false }); -let server: ReturnType; -let instanceUrl: string; - -beforeAll(async () => { - if (skipIntegration) return; - await db.raw('select 1'); - await new Promise((resolve) => { - server = app.listen(0, () => { - const port = (server.address() as AddressInfo).port; - instanceUrl = `http://127.0.0.1:${port}`; - // Pin the router URL so federation doesn't try to swap ports. - process.env.NETWORK_ROUTER_URL = instanceUrl; - resolve(); - }); - }); -}); - -afterAll(async () => { - if (server) await new Promise((resolve) => server.close(() => resolve())); - await db.destroy(); -}); - -beforeEach(async () => { - if (skipIntegration) return; - for (const t of TABLES_TO_CLEAN) { - await db(t).del(); - } -}); - -describe.skipIf(skipIntegration)('openpartner network', () => { - it('vendor → offering → creator → request → approve federates a partner + link', async () => { - // Create a campaign on the "vendor's instance" (same server in tests). - const campaignRes = await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Referral', commissionRule: { type: 'percent', value: 30, recurring: true } }); - expect(campaignRes.status).toBe(201); - const vendorCampaignId = campaignRes.body.id; - - // Register vendor on the Network (admin-gated). - const vendorRegRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ - name: 'Acme', - slug: 'acme', - websiteUrl: 'https://acme.example', - instanceUrl, - instanceKey: ADMIN_KEY, - }); - expect(vendorRegRes.status).toBe(201); - const vendorId = vendorRegRes.body.vendor.id; - const vendorApiKey = vendorRegRes.body.apiKey; - - // Admin activates the vendor. - await request(app) - .post(`/network/vendors/${vendorId}/activate`) - .set('Authorization', `Bearer ${ADMIN_KEY}`); - - // Vendor publishes an offering. - const offeringRes = await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorApiKey}`) - .send({ - title: 'Acme Pro — 30% for 6 months', - productUrl: 'https://acme.example/pro', - description: 'Sell our flagship to your audience.', - vendorCampaignId, - terms: { - payout: { type: 'recurring_percent', percent: 30, durationMonths: 6 }, - bonuses: [{ description: '$500 at $10k MRR', triggerRevenueUsd: 10000, bonusUsd: 500 }], - cookieWindowDays: 60, - }, - published: true, - }); - expect(offeringRes.status).toBe(201); - const offeringId = offeringRes.body.offering.id; - - // Admin onboards a creator. - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ - name: 'Grace Hopper', - handle: 'gracie', - email: 'grace@example.com', - platforms: [{ platform: 'youtube', url: 'https://youtube.com/@gracie', followers: 120000 }], - }); - expect(creatorRes.status).toBe(201); - const creatorId = creatorRes.body.creator.id; - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorId}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - // Creator sees the directory. - const dirRes = await request(app).get('/network/directory/offerings'); - expect(dirRes.status).toBe(200); - expect(dirRes.body.offerings).toHaveLength(1); - expect(dirRes.body.offerings[0].title).toContain('Acme'); - - // Creator applies. - const applyRes = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId, message: 'I have 120k subs interested in this.' }); - expect(applyRes.status).toBe(201); - const requestId = applyRes.body.request.id; - - // Vendor approves (this federates). - const approveRes = await request(app) - .post(`/network/requests/${requestId}/approve`) - .set('Authorization', `Bearer ${vendorApiKey}`) - .send({}); - expect(approveRes.status).toBe(200); - expect(approveRes.body.partnership.status).toBe('active'); - expect(approveRes.body.federated.partnerId).toBeTruthy(); - expect(approveRes.body.federated.linkKey).toBe('gracie'); - expect(approveRes.body.federated.publicShareUrl).toBe(`${instanceUrl}/r/gracie`); - - // The vendor's instance actually has the partner + link now. - const partnerOnVendor = await db(TABLES.Partner).where({ id: approveRes.body.federated.partnerId }).first(); - expect(partnerOnVendor).toBeDefined(); - expect(partnerOnVendor!.email).toBe('grace@example.com'); - expect(partnerOnVendor!.metadata).toMatchObject({ source: 'openpartner_network' }); - - const linkOnVendor = await db(TABLES.Link).where({ linkKey: 'gracie' }).first(); - expect(linkOnVendor).toBeDefined(); - expect(linkOnVendor!.campaignId).toBe(vendorCampaignId); - }); - - it('creator cannot double-apply to the same offering', async () => { - const campaignRes = await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'C', commissionRule: { type: 'percent', value: 10 } }); - const vendorCampaignId = campaignRes.body.id; - - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Vendo', slug: `vendo-${Date.now()}`, instanceUrl, instanceKey: ADMIN_KEY }); - const vendorApiKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offeringRes = await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorApiKey}`) - .send({ - title: 'Offer', - productUrl: 'https://v.example', - vendorCampaignId, - terms: { payout: { type: 'one_time_fee', amount: 50 }, cookieWindowDays: 30 }, - published: true, - }); - const offeringId = offeringRes.body.offering.id; - - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Eva', handle: `eva_${Date.now()}`, email: `eva${Date.now()}@e.com` }); - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorRes.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const first = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId }); - expect(first.status).toBe(201); - - const second = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId }); - expect(second.status).toBe(409); - }); - - it('concurrent /approve calls do not both federate — one wins, the other 409s', async () => { - // Regression for the ultrareview race. Both calls see status='pending' - // on the SELECT; the conditional UPDATE pending→approving admits one. - const campaignRes = await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Race', commissionRule: { type: 'percent', value: 15 } }); - const vendorCampaignId = campaignRes.body.id; - - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'RaceVendor', slug: `race-${Date.now()}`, instanceUrl, instanceKey: ADMIN_KEY }); - const vendorApiKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offeringRes = await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorApiKey}`) - .send({ - title: 'RaceOffer', - productUrl: 'https://race.example', - vendorCampaignId, - terms: { payout: { type: 'one_time_fee', amount: 25 }, cookieWindowDays: 30 }, - published: true, - }); - const offeringId = offeringRes.body.offering.id; - - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Rico', handle: `rico_${Date.now()}`, email: `rico${Date.now()}@e.com` }); - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorRes.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const reqRes = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId }); - const reqId = reqRes.body.request.id; - - // Fire two approvals simultaneously. - const [a, b] = await Promise.all([ - request(app).post(`/network/requests/${reqId}/approve`).set('Authorization', `Bearer ${vendorApiKey}`), - request(app).post(`/network/requests/${reqId}/approve`).set('Authorization', `Bearer ${vendorApiKey}`), - ]); - const statuses = [a.status, b.status].sort(); - // One succeeds (200), the other loses the CAS race (409). - expect(statuses).toEqual([200, 409]); - - // And we only federated ONE Partnership for this request. - const partnerships = await db(TABLES.Partnership).where({ requestId: reqId }); - expect(partnerships).toHaveLength(1); - }); - - it('creator-chosen promo code becomes the share-link slug', async () => { - const campaign = (await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Promo test', commissionRule: { type: 'percent', value: 20 } })).body; - - const vendorRouter = `${instanceUrl}`; // point router at same server for the test - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ - name: 'Coherence', - slug: `coherence-${Date.now()}`, - instanceUrl, - instanceKey: ADMIN_KEY, - routerUrl: vendorRouter, - }); - const vendorKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offeringRes = await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - title: 'Coherence Pro', - productUrl: 'https://getcoherence.io/pro', - vendorCampaignId: campaign.id, - terms: { payout: { type: 'recurring_percent', percent: 20, durationMonths: null }, cookieWindowDays: 60 }, - published: true, - }); - const offeringId = offeringRes.body.offering.id; - - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Grace', handle: `g_${Date.now()}`, email: `g${Date.now()}@e.com` }); - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorRes.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const applyRes = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId, promoCode: 'graciefindsdeals' }); - expect(applyRes.status).toBe(201); - expect(applyRes.body.request.promoCode).toBe('graciefindsdeals'); - - const approveRes = await request(app) - .post(`/network/requests/${applyRes.body.request.id}/approve`) - .set('Authorization', `Bearer ${vendorKey}`) - .send({}); - expect(approveRes.status).toBe(200); - expect(approveRes.body.federated.linkKey).toBe('graciefindsdeals'); - expect(approveRes.body.federated.publicShareUrl).toBe(`${vendorRouter}/r/graciefindsdeals`); - - const linkOnVendor = await db(TABLES.Link).where({ linkKey: 'graciefindsdeals' }).first(); - expect(linkOnVendor).toBeDefined(); - }); - - it('defaults to creator default promo code, falls back to handle', async () => { - const campaign = (await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Defaults', commissionRule: { type: 'percent', value: 10 } })).body; - - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'DefaultCo', slug: `default-${Date.now()}`, instanceUrl, instanceKey: ADMIN_KEY }); - const vendorKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offering = (await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - title: 'Offering One', - productUrl: 'https://example.com', - vendorCampaignId: campaign.id, - terms: { payout: { type: 'one_time_fee', amount: 1 }, cookieWindowDays: 30 }, - published: true, - })).body.offering; - - // Creator WITH a default - const handle = `ada_${Date.now()}`; - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Ada', handle, email: `ada${Date.now()}@e.com`, defaultPromoCode: 'ada-picks' }); - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorRes.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - // No promoCode on the request — should use the creator's default. - const req1 = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId: offering.id }); - expect(req1.body.request.promoCode).toBe('ada-picks'); - - // Creator WITHOUT a default → handle - const handle2 = `rose_${Date.now()}`; - const c2 = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Rose', handle: handle2, email: `rose${Date.now()}@e.com` }); - const c2key = c2.body.apiKey; - await request(app).post(`/network/creators/${c2.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const o2 = (await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - title: 'Offering Two', - productUrl: 'https://example.com', - vendorCampaignId: campaign.id, - terms: { payout: { type: 'one_time_fee', amount: 2 }, cookieWindowDays: 30 }, - published: true, - })).body.offering; - - const req2 = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${c2key}`) - .send({ offeringId: o2.id }); - expect(req2.body.request.promoCode).toBe(handle2); - }); - - it('earnings endpoint federates a read and surfaces per-partnership stats', async () => { - // Campaign with a 20% recurring rule on the vendor's instance - const campaign = (await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Earn test', commissionRule: { type: 'percent', value: 20 } })).body; - - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'EarnVendor', slug: `earn-${Date.now()}`, instanceUrl, instanceKey: ADMIN_KEY, routerUrl: instanceUrl }); - const vendorKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offeringId = (await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - title: 'Earning offering', - productUrl: 'https://example.com/pro', - vendorCampaignId: campaign.id, - terms: { payout: { type: 'recurring_percent', percent: 20, durationMonths: 6 }, cookieWindowDays: 60 }, - published: true, - })).body.offering.id; - - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Earner', handle: `earner_${Date.now()}`, email: `earner${Date.now()}@e.com` }); - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorRes.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const reqRes = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId, promoCode: 'earntest' }); - await request(app) - .post(`/network/requests/${reqRes.body.request.id}/approve`) - .set('Authorization', `Bearer ${vendorKey}`) - .send({}); - - // Simulate traffic on the vendor's side: click → identify → event. - // (We write Click directly because the router is a separate server in - // prod; the dashboard endpoint doesn't care how Clicks got there.) - const link = await db(TABLES.Link).where({ linkKey: 'earntest' }).first(); - expect(link).toBeDefined(); - const clickId = ulid(); - await db(TABLES.Click).insert({ - id: clickId, - linkId: link!.id, - partnerId: link!.partnerId, - campaignId: link!.campaignId, - landingUrl: 'https://example.com/pro', - ipHash: 'x', - userAgent: 'x', - referer: null, - fraudFlag: null, - }); - - const userId = `viewer_${Date.now()}`; - await request(app).post('/attribution/identify').send({ cref: clickId, userId }); - await request(app) - .post('/attribution/events') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ userId, type: 'invoice_paid', value: 250 }); - - // Creator pulls their earnings via the Network's federated read. - const earnings = await request(app) - .get('/network/partnerships/earnings') - .set('Authorization', `Bearer ${creatorKey}`); - expect(earnings.status).toBe(200); - expect(earnings.body.partnerships).toHaveLength(1); - const row = earnings.body.partnerships[0]; - expect(row.status).toBe('ok'); - expect(row.stats.clicks).toBe(1); - expect(row.stats.attributedEvents).toBe(1); - expect(row.stats.attributedRevenue).toBe(250); - expect(row.stats.commissionByStatus.accrued).toBe(50); // 20% of 250 - - expect(earnings.body.totals.clicks).toBe(1); - expect(earnings.body.totals.attributedRevenue).toBe(250); - expect(earnings.body.totals.commission.accrued).toBe(50); - expect(earnings.body.totals.unreachable).toBe(0); - expect(earnings.body.totals.healthy).toBe(1); - }); - - it('earnings endpoint surfaces unreachable vendors without blacking out', async () => { - const campaign = (await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Unreach', commissionRule: { type: 'percent', value: 10 } })).body; - - // Register the vendor against a dead URL so federation will fail. - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ - name: 'DeadVendor', - slug: `dead-${Date.now()}`, - instanceUrl: 'http://127.0.0.1:1', // port 1 — nothing listens here - instanceKey: ADMIN_KEY, - }); - const vendorKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - // Insert a Partnership directly so we don't have to federate-create - // one against the dead instance. - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Drift', handle: `drift_${Date.now()}`, email: `drift${Date.now()}@e.com` }); - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorRes.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offeringId = (await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - title: 'Offline offering', - productUrl: 'https://example.com', - vendorCampaignId: campaign.id, - terms: { payout: { type: 'one_time_fee', amount: 10 }, cookieWindowDays: 30 }, - published: true, - })).body.offering.id; - - await db(TABLES.PartnershipRequest).insert({ - id: ulid(), - offeringId, - vendorId: vendorRes.body.vendor.id, - creatorId: creatorRes.body.creator.id, - direction: 'creator_to_vendor', - status: 'approved', - promoCode: 'drift', - decidedAt: new Date(), - }); - const partnershipId = ulid(); - const lastReq = await db(TABLES.PartnershipRequest).where({ creatorId: creatorRes.body.creator.id }).first(); - await db(TABLES.Partnership).insert({ - id: partnershipId, - requestId: lastReq!.id, - offeringId, - vendorId: vendorRes.body.vendor.id, - creatorId: creatorRes.body.creator.id, - vendorPartnerId: 'phantom', - vendorLinkKey: 'drift', - publicShareUrl: 'http://127.0.0.1:1/r/drift', - status: 'active', - }); - - const earnings = await request(app) - .get('/network/partnerships/earnings') - .set('Authorization', `Bearer ${creatorKey}`); - expect(earnings.status).toBe(200); - expect(earnings.body.partnerships).toHaveLength(1); - expect(earnings.body.partnerships[0].status).toBe('error'); - expect(earnings.body.totals.unreachable).toBe(1); - expect(earnings.body.totals.clicks).toBe(0); - }); - - it('full network federation works with a scoped key (not admin)', async () => { - // Mint a scoped key on the "vendor instance" with exactly the - // federation permission set. Register the vendor with THAT key. - const scopedMint = await request(app) - .post('/api-keys/scoped') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ scopes: ['partners:write', 'partners:read', 'links:write', 'commissions:read'] }); - const scopedKey = scopedMint.body.plaintext as string; - - const campaign = (await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Scoped test', commissionRule: { type: 'percent', value: 15 } })).body; - - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ - name: 'ScopedCo', - slug: `scoped-${Date.now()}`, - instanceUrl, - instanceKey: scopedKey, // <-- scoped, not ADMIN_KEY - routerUrl: instanceUrl, - }); - const vendorKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offeringId = (await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - title: 'Scoped offering', - productUrl: 'https://example.com', - vendorCampaignId: campaign.id, - terms: { payout: { type: 'recurring_percent', percent: 15, durationMonths: null }, cookieWindowDays: 45 }, - published: true, - })).body.offering.id; - - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Scopy', handle: `scopy_${Date.now()}`, email: `scopy${Date.now()}@e.com` }); - const creatorKey = creatorRes.body.apiKey; - await request(app).post(`/network/creators/${creatorRes.body.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const applyRes = await request(app) - .post('/network/requests') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ offeringId, promoCode: 'scopyshares' }); - - const approveRes = await request(app) - .post(`/network/requests/${applyRes.body.request.id}/approve`) - .set('Authorization', `Bearer ${vendorKey}`) - .send({}); - expect(approveRes.status).toBe(200); - expect(approveRes.body.federated.linkKey).toBe('scopyshares'); - - // Federated read (commissions:read) works too. - const earnings = await request(app) - .get('/network/partnerships/earnings') - .set('Authorization', `Bearer ${creatorKey}`); - expect(earnings.status).toBe(200); - expect(earnings.body.partnerships[0].status).toBe('ok'); - }); - - it('verify-key endpoint flags unrestricted admin keys and accepts proper scoped keys', async () => { - // Unrestricted admin → warn. - const adminCheck = await request(app) - .post('/network/vendors/verify-key') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ instanceUrl, instanceKey: ADMIN_KEY }); - expect(adminCheck.status).toBe(200); - expect(adminCheck.body.unrestricted).toBe(true); - expect(adminCheck.body.acceptable).toBe(true); - - // Scoped with all required → acceptable, missing=[]. - const fullyScoped = await request(app) - .post('/api-keys/scoped') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ scopes: ['partners:write', 'partners:read', 'links:write', 'commissions:read'] }); - const okCheck = await request(app) - .post('/network/vendors/verify-key') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ instanceUrl, instanceKey: fullyScoped.body.plaintext }); - expect(okCheck.status).toBe(200); - expect(okCheck.body.unrestricted).toBe(false); - expect(okCheck.body.missing).toEqual([]); - expect(okCheck.body.acceptable).toBe(true); - - // Scoped with only some → missing listed. - const partial = await request(app) - .post('/api-keys/scoped') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ scopes: ['partners:write'] }); - const missCheck = await request(app) - .post('/network/vendors/verify-key') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ instanceUrl, instanceKey: partial.body.plaintext }); - expect(missCheck.status).toBe(200); - expect(missCheck.body.missing).toEqual( - expect.arrayContaining(['partners:read', 'links:write', 'commissions:read']), - ); - expect(missCheck.body.acceptable).toBe(false); - }); - - it('creator profile patch + directory visibility', async () => { - const creatorRes = await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Start', handle: `start_${Date.now()}`, email: `start${Date.now()}@e.com` }); - const creatorKey = creatorRes.body.apiKey; - const creatorId = creatorRes.body.creator.id; - - // Inactive creators don't appear in the public directory. - let dir = await request(app).get('/network/directory/creators'); - expect(dir.body.creators.find((c: { id: string }) => c.id === creatorId)).toBeUndefined(); - - await request(app).post(`/network/creators/${creatorId}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - dir = await request(app).get('/network/directory/creators'); - expect(dir.body.creators.find((c: { id: string }) => c.id === creatorId)).toBeDefined(); - - // Self-edit persists. - const patch = await request(app) - .patch('/network/creators/me') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ - name: 'Patched', - bio: 'I publish on YouTube.', - defaultPromoCode: 'patchedcode', - platforms: [{ platform: 'youtube', url: 'https://youtube.com/@patched', followers: 50000 }], - }); - expect(patch.status).toBe(200); - expect(patch.body.creator.name).toBe('Patched'); - expect(patch.body.creator.bio).toBe('I publish on YouTube.'); - expect(patch.body.creator.defaultPromoCode).toBe('patchedcode'); - expect(patch.body.creator.platforms).toHaveLength(1); - - // Handle is not in the PATCH schema — it stays pinned. - const handleAttempt = await request(app) - .patch('/network/creators/me') - .set('Authorization', `Bearer ${creatorKey}`) - .send({ handle: 'renamed' }); - expect(handleAttempt.status).toBe(200); - expect(handleAttempt.body.creator.handle).not.toBe('renamed'); - }); - - it('vendor invite via /network/invites creates a pending request', async () => { - const campaign = (await request(app) - .post('/campaigns') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Inv', commissionRule: { type: 'percent', value: 10 } })).body; - - const vendorRes = await request(app) - .post('/network/vendors') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'InviteVendor', slug: `inv-${Date.now()}`, instanceUrl, instanceKey: ADMIN_KEY }); - const vendorKey = vendorRes.body.apiKey; - await request(app).post(`/network/vendors/${vendorRes.body.vendor.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const offering = (await request(app) - .post('/network/offerings') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - title: 'Invite offering', - productUrl: 'https://example.com', - vendorCampaignId: campaign.id, - terms: { payout: { type: 'one_time_fee', amount: 1 }, cookieWindowDays: 30 }, - published: true, - })).body.offering; - - const creator = (await request(app) - .post('/network/creators') - .set('Authorization', `Bearer ${ADMIN_KEY}`) - .send({ name: 'Targ', handle: `targ_${Date.now()}`, email: `targ${Date.now()}@e.com` })).body; - await request(app).post(`/network/creators/${creator.creator.id}/activate`).set('Authorization', `Bearer ${ADMIN_KEY}`); - - const invite = await request(app) - .post('/network/invites') - .set('Authorization', `Bearer ${vendorKey}`) - .send({ - offeringId: offering.id, - creatorId: creator.creator.id, - message: 'Want to be part of this?', - promoCode: 'targshare', - }); - expect(invite.status).toBe(201); - expect(invite.body.request.direction).toBe('vendor_to_creator'); - expect(invite.body.request.promoCode).toBe('targshare'); - }); - - it('encryption round-trips', async () => { - const { encryptKey, decryptKey } = await import('../network/crypto.js'); - const enc = encryptKey('hello-secret-key'); - expect(enc).not.toContain('hello'); - expect(decryptKey(enc)).toBe('hello-secret-key'); - }); -}); diff --git a/apps/api/src/__tests__/safe-fetch.test.ts b/apps/api/src/__tests__/safe-fetch.test.ts deleted file mode 100644 index 1a01256..0000000 --- a/apps/api/src/__tests__/safe-fetch.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * safeFetch: SSRF guard on outbound Network calls. - * - * We can't cheaply test a real fetch without a network, so this file - * exercises the protocol + hostname validation that safeFetch does - * BEFORE calling fetch. Runs under NODE_ENV=production to skip the - * test-env bypass baked into the guard. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { safeFetch } from '../network/safe-fetch.js'; - -describe('safeFetch SSRF guard', () => { - const original = process.env.NODE_ENV; - beforeEach(() => { - process.env.NODE_ENV = 'production'; - delete process.env.NETWORK_ALLOW_PRIVATE_HOSTS; - }); - afterEach(() => { - process.env.NODE_ENV = original; - }); - - it('rejects non-http(s) protocols', async () => { - await expect(safeFetch('file:///etc/passwd')).rejects.toMatchObject({ code: 'unsupported_protocol' }); - await expect(safeFetch('gopher://example.com/')).rejects.toMatchObject({ code: 'unsupported_protocol' }); - }); - - it('rejects IPv4 loopback / private ranges by literal IP', async () => { - await expect(safeFetch('http://127.0.0.1/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - await expect(safeFetch('http://10.0.0.1/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - await expect(safeFetch('http://192.168.1.1/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - await expect(safeFetch('http://169.254.169.254/latest/meta-data/')).rejects.toMatchObject({ - code: 'private_host_blocked', - }); - await expect(safeFetch('http://172.16.0.1/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - }); - - it('rejects IPv6 loopback / link-local / unique-local', async () => { - await expect(safeFetch('http://[::1]/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - await expect(safeFetch('http://[fe80::1]/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - await expect(safeFetch('http://[fc00::1]/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - }); - - it('rejects hostnames that resolve to loopback (localhost)', async () => { - // localhost normally resolves to 127.0.0.1 or ::1 via the hosts file. - await expect(safeFetch('http://localhost/')).rejects.toMatchObject({ code: 'private_host_blocked' }); - }); - - it('opts out of the guard when NETWORK_ALLOW_PRIVATE_HOSTS=1', async () => { - process.env.NETWORK_ALLOW_PRIVATE_HOSTS = '1'; - // We still expect the request itself to fail (nothing listening at - // this port in the test env), but NOT with private_host_blocked. - await expect(safeFetch('http://127.0.0.1:59999/', { signal: AbortSignal.timeout(200) })).rejects.not.toMatchObject( - { code: 'private_host_blocked' }, - ); - }); -}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c13fb91..c9674f7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -22,15 +22,9 @@ import { exportRouter } from './routes/export.js'; import { billingRouter } from './routes/billing.js'; import { authRouter } from './routes/auth.js'; import { adminOverviewRouter } from './routes/admin-overview.js'; -import { magicLinkRouter } from './routes/magic-link.js'; import { funnelRouter } from './routes/funnel.js'; import { fraudReviewRouter } from './routes/fraud-review.js'; import { webhooksRouter } from './routes/webhooks.js'; -import { networkVendorsRouter } from './routes/network-vendors.js'; -import { networkCreatorsRouter } from './routes/network-creators.js'; -import { networkOfferingsRouter } from './routes/network-offerings.js'; -import { networkRequestsRouter } from './routes/network-requests.js'; -import { networkEarningsRouter } from './routes/network-earnings.js'; import { metricsRouter } from './routes/metrics.js'; export function createApp(options: { enableLogger?: boolean } = {}) { @@ -107,7 +101,6 @@ export function createApp(options: { enableLogger?: boolean } = {}) { }); app.use(authRouter); - app.use(magicLinkRouter); app.use(funnelRouter); app.use(fraudReviewRouter); app.use(webhooksRouter); @@ -124,11 +117,6 @@ export function createApp(options: { enableLogger?: boolean } = {}) { app.use(exportRouter); app.use(billingRouter); app.use(adminOverviewRouter); - app.use(networkVendorsRouter); - app.use(networkCreatorsRouter); - app.use(networkOfferingsRouter); - app.use(networkRequestsRouter); - app.use(networkEarningsRouter); app.use(metricsRouter); app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { diff --git a/apps/api/src/auth-sessions.ts b/apps/api/src/auth-sessions.ts deleted file mode 100644 index d326540..0000000 --- a/apps/api/src/auth-sessions.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Magic-link + session primitives. - * - * Tokens and session tokens share the same sha256-at-rest pattern API - * keys already use: we store prefix (for indexed lookup) + hash, never - * the plaintext. Cookies carry the plaintext; comparisons use - * constant-time equality on the hash. - */ - -import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; -import { ulid } from 'ulid'; -import { - TABLES, - type MagicLinkClaim, - type MagicLinkPurpose, - type MagicLinkTokenRow, - type SessionPrincipalKind, - type SessionRow, -} from '@openpartner/db'; -import { db } from './db.js'; - -export const SESSION_COOKIE_NAME = 'op_session'; -const MAGIC_PREFIX_LEN = 8; -const SESSION_PREFIX_LEN = 8; - -function hash(s: string): string { - return createHash('sha256').update(s).digest('hex'); -} - -function constantTimeStringEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - return timingSafeEqual(Buffer.from(a), Buffer.from(b)); -} - -// ---- Magic-link tokens ---- - -export interface IssuedToken { - id: string; - plaintext: string; -} - -export async function issueMagicLink(params: { - email: string; - purpose: MagicLinkPurpose; - claim?: MagicLinkClaim; - ttlSeconds?: number; -}): Promise { - const plaintext = `mlt_${randomBytes(32).toString('base64url')}`; - const prefix = plaintext.slice(0, MAGIC_PREFIX_LEN); - const tokenHash = hash(plaintext); - const id = ulid(); - const expiresAt = new Date(Date.now() + (params.ttlSeconds ?? 15 * 60) * 1000); // 15 min default - - await db(TABLES.MagicLinkToken).insert({ - id, - prefix, - tokenHash, - email: params.email.toLowerCase(), - purpose: params.purpose, - claim: params.claim ? (JSON.stringify(params.claim) as unknown as never) : null, - expiresAt, - }); - - return { id, plaintext }; -} - -export type ConsumeResult = - | { ok: true; token: MagicLinkTokenRow } - | { ok: false; error: 'not_found' | 'expired' | 'already_consumed' }; - -export async function consumeMagicLink(plaintext: string): Promise { - if (plaintext.length < MAGIC_PREFIX_LEN) return { ok: false, error: 'not_found' }; - const prefix = plaintext.slice(0, MAGIC_PREFIX_LEN); - const tokenHash = hash(plaintext); - - const candidates = await db(TABLES.MagicLinkToken).where({ prefix }); - const match = candidates.find((row) => constantTimeStringEqual(row.tokenHash, tokenHash)); - if (!match) return { ok: false, error: 'not_found' }; - if (match.consumedAt) return { ok: false, error: 'already_consumed' }; - if (new Date(match.expiresAt).getTime() < Date.now()) return { ok: false, error: 'expired' }; - - // Atomic single-use consumption: conditional update on consumedAt IS NULL. - const updated = await db(TABLES.MagicLinkToken) - .where({ id: match.id }) - .whereNull('consumedAt') - .update({ consumedAt: new Date() }) - .returning('*'); - - if (updated.length === 0) return { ok: false, error: 'already_consumed' }; - return { ok: true, token: updated[0]! }; -} - -// ---- Sessions ---- - -const SESSION_TTL_DAYS = 30; - -export async function createSession(params: { - principalKind: SessionPrincipalKind; - principalId: string; -}): Promise { - const plaintext = `ops_${randomBytes(32).toString('base64url')}`; - const prefix = plaintext.slice(0, SESSION_PREFIX_LEN); - const tokenHash = hash(plaintext); - const id = ulid(); - const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000); - - await db(TABLES.Session).insert({ - id, - prefix, - tokenHash, - principalKind: params.principalKind, - principalId: params.principalId, - expiresAt, - lastSeenAt: new Date(), - }); - - return { id, plaintext }; -} - -export async function resolveSession(plaintext: string): Promise { - if (plaintext.length < SESSION_PREFIX_LEN) return null; - const prefix = plaintext.slice(0, SESSION_PREFIX_LEN); - const tokenHash = hash(plaintext); - - const candidates = await db(TABLES.Session) - .where({ prefix }) - .whereNull('revokedAt'); - const match = candidates.find((row) => constantTimeStringEqual(row.tokenHash, tokenHash)); - if (!match) return null; - if (new Date(match.expiresAt).getTime() < Date.now()) return null; - - // Non-blocking lastSeen bump — we don't await. - void db(TABLES.Session).where({ id: match.id }).update({ lastSeenAt: new Date() }); - return match; -} - -export async function revokeSession(id: string): Promise { - await db(TABLES.Session).where({ id }).update({ revokedAt: new Date() }); -} - -export function sessionCookieOptions() { - return { - httpOnly: true, - sameSite: 'lax' as const, - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: SESSION_TTL_DAYS * 24 * 60 * 60 * 1000, - }; -} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 8710f8a..619a9dd 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -3,11 +3,13 @@ * * Two shapes of credential: * - ADMIN_API_KEY env var — bootstrap admin key, valid in all modes. - * - ApiKey rows in the database — either admin (partnerId null) or partner-scoped. + * - ApiKey rows in the database — either admin (partnerId null), + * partner-scoped, or a scoped key used by a federating client like + * an OpenPartner Network hub. * - * We never store plaintext. Keys look like `op_<24 hex>` and are identified by - * an 8-char prefix so lookups are indexed rather than table scans. The hash is - * sha256 over the whole key. + * We never store plaintext. Keys look like `op_<24 hex>` and are identified + * by an 8-char prefix so lookups are indexed rather than table scans. The + * hash is sha256 over the whole key. */ import { createHash, randomBytes } from 'node:crypto'; @@ -20,8 +22,6 @@ export type ApiKeyPrincipal = | { role: 'admin'; source: 'env' } | { role: 'admin'; source: 'db'; apiKeyId: string } | { role: 'partner'; source: 'db'; apiKeyId: string; partnerId: string } - | { role: 'network_vendor'; source: 'db' | 'session'; apiKeyId?: string; sessionId?: string; networkVendorId: string } - | { role: 'network_creator'; source: 'db' | 'session'; apiKeyId?: string; sessionId?: string; networkCreatorId: string } | { role: 'scoped'; source: 'db'; apiKeyId: string; scopes: string[] }; declare global { @@ -78,33 +78,8 @@ export function requirePartnerOrAdmin(paramName: string = 'id') { async function resolvePrincipal(req: Request): Promise { const header = req.header('authorization'); - if (!header) { - // No Bearer — try the session cookie instead. This is what the - // portal uses after a creator signs in via magic link. - const cookie = (req as unknown as { cookies?: Record }).cookies?.op_session; - if (!cookie) return null; - const { resolveSession } = await import('./auth-sessions.js'); - const session = await resolveSession(cookie); - if (!session) return null; - if (session.principalKind === 'network_creator') { - return { - role: 'network_creator', - source: 'session', - sessionId: session.id, - networkCreatorId: session.principalId, - }; - } - if (session.principalKind === 'network_vendor') { - return { - role: 'network_vendor', - source: 'session', - sessionId: session.id, - networkVendorId: session.principalId, - }; - } - // Future: partner / admin session kinds if we add human auth for them. - return null; - } + if (!header) return null; + const match = /^Bearer\s+(\S+)$/i.exec(header); if (!match) return null; const token = match[1]!; @@ -126,17 +101,11 @@ async function resolvePrincipal(req: Request): Promise { // Non-blocking last-used bump. void db(TABLES.ApiKey).where({ id: match2.id }).update({ lastUsedAt: new Date() }); - // Scoped keys take precedence over any FK role. The FK columns are - // only meaningful for non-scoped keys (admin / partner / vendor / creator). + // Scoped keys take precedence — they're used by Network-style federation + // where the caller has a narrow permission set rather than a role. if (Array.isArray(match2.scopes)) { return { role: 'scoped', source: 'db', apiKeyId: match2.id, scopes: match2.scopes }; } - if (match2.networkVendorId) { - return { role: 'network_vendor', source: 'db', apiKeyId: match2.id, networkVendorId: match2.networkVendorId }; - } - if (match2.networkCreatorId) { - return { role: 'network_creator', source: 'db', apiKeyId: match2.id, networkCreatorId: match2.networkCreatorId }; - } if (match2.partnerId) { return { role: 'partner', source: 'db', apiKeyId: match2.id, partnerId: match2.partnerId }; } @@ -178,8 +147,6 @@ function constantTimeEqual(a: string, b: string): boolean { export async function createApiKeyRow(params: { partnerId?: string | null; - networkVendorId?: string | null; - networkCreatorId?: string | null; scopes?: string[] | null; label?: string; }): Promise<{ id: string; plaintext: string }> { @@ -190,8 +157,6 @@ export async function createApiKeyRow(params: { prefix, keyHash: hash, partnerId: params.partnerId ?? null, - networkVendorId: params.networkVendorId ?? null, - networkCreatorId: params.networkCreatorId ?? null, // pg jsonb: arrays need stringification; null stays null scopes: params.scopes != null ? (JSON.stringify(params.scopes) as unknown as never) @@ -200,17 +165,3 @@ export async function createApiKeyRow(params: { }); return { id, plaintext }; } - -export function requireNetworkVendor(req: Request, res: Response, next: NextFunction): void { - const p = req.principal; - if (!p) return void res.status(401).json({ error: 'unauthorized' }); - if (p.role === 'admin' || p.role === 'network_vendor') return next(); - res.status(403).json({ error: 'forbidden' }); -} - -export function requireNetworkCreator(req: Request, res: Response, next: NextFunction): void { - const p = req.principal; - if (!p) return void res.status(401).json({ error: 'unauthorized' }); - if (p.role === 'admin' || p.role === 'network_creator') return next(); - res.status(403).json({ error: 'forbidden' }); -} diff --git a/apps/api/src/email-templates.ts b/apps/api/src/email-templates.ts deleted file mode 100644 index 70e866f..0000000 --- a/apps/api/src/email-templates.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * HTML + plain-text templates for auth emails. - * - * Kept deliberately small: inline CSS only, safe fonts, single CTA - * button. Plaintext fallback carries the same link for clients that - * block HTML. Preheader text primes the inbox preview so the user sees - * "Sign in to OpenPartner" before they open the message. - */ - -export interface MagicEmail { - subject: string; - text: string; - html: string; - tag: string; -} - -interface BuildParams { - headline: string; - preheader: string; - intro: string; - buttonLabel: string; - url: string; - note?: string; - tag: string; - subject: string; -} - -function esc(s: string): string { - return s - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function build(params: BuildParams): MagicEmail { - const { headline, preheader, intro, buttonLabel, url, note, tag, subject } = params; - - const text = - `${headline}\n\n${intro}\n\n${buttonLabel}: ${url}\n\n` + - `This link expires in 15 minutes. If you didn't request it, ignore this email.` + - (note ? `\n\n${note}` : ''); - - const html = ` - - - -${esc(subject)} - - - ${esc(preheader)} - - - - -
- - - - - - - - - - - - - - - - - ${note ? `` : ''} - - - -
- - - - - -
OOpenPartner
-
${esc(headline)}
${esc(intro)}
- ${esc(buttonLabel)} -
- This link expires in 15 minutes. If you didn't request it, ignore this email. -
${esc(note)}
- Or paste this URL into your browser:
- ${esc(url)} -
-
- -`; - - return { subject, text, html, tag }; -} - -export function creatorSignupEmail(name: string, url: string): MagicEmail { - return build({ - subject: 'Finish your OpenPartner signup', - preheader: `Hi ${name} — one click to finish creating your OpenPartner account.`, - headline: `Hi ${name}, one click to finish.`, - intro: - 'Click the button below to verify your email and finish setting up your OpenPartner creator account. Once verified, you can browse offerings and apply to promote any product.', - buttonLabel: 'Finish signup', - url, - tag: 'creator_signup', - }); -} - -export function creatorSigninEmail(url: string): MagicEmail { - return build({ - subject: 'Your OpenPartner sign-in link', - preheader: 'One click to sign in to OpenPartner.', - headline: 'Sign in to OpenPartner', - intro: 'Click the button below to sign in.', - buttonLabel: 'Sign in', - url, - tag: 'creator_signin', - }); -} - -export function vendorSignupEmail(name: string, url: string): MagicEmail { - return build({ - subject: 'Finish your OpenPartner vendor signup', - preheader: `Verify your email to submit ${name} for Network review.`, - headline: `Welcome, ${name}.`, - intro: - 'Click the button below to verify your email and submit your vendor application. An admin reviews your federation credentials and activates your account — usually within a day.', - buttonLabel: 'Verify email', - url, - tag: 'vendor_signup', - note: - "After your account is active, creators on the Network will be able to discover and apply to promote your offerings. You'll receive their applications in your portal inbox.", - }); -} - -export function vendorSigninEmail(url: string): MagicEmail { - return build({ - subject: 'Your OpenPartner sign-in link', - preheader: 'One click to sign in to OpenPartner.', - headline: 'Sign in to OpenPartner', - intro: 'Click the button below to sign in.', - buttonLabel: 'Sign in', - url, - tag: 'vendor_signin', - }); -} diff --git a/apps/api/src/mailer.ts b/apps/api/src/mailer.ts deleted file mode 100644 index a571cb1..0000000 --- a/apps/api/src/mailer.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Mail delivery. The abstraction stays tiny — a single send() that takes - * { to, subject, text, html?, metadata? }. - * - * Two implementations: - * DevMailer persists to the DevMessage table. An admin-only - * /dev/mailbox endpoint reads them back so local dev - * and CI can follow magic links without configuring - * a real provider. - * PostmarkMailer POSTs to Postmark's Email API over native fetch - * (no SDK dep). Used in any environment with - * MAIL_TRANSPORT=postmark set. - * - * The factory reads MAIL_TRANSPORT: - * "postmark" → PostmarkMailer (requires POSTMARK_SERVER_TOKEN + MAIL_FROM) - * anything else → DevMailer - */ - -import { ulid } from 'ulid'; -import { TABLES, type DevMessageRow } from '@openpartner/db'; -import { db } from './db.js'; - -export interface MailMessage { - to: string; - subject: string; - text: string; - html?: string; - metadata?: Record; - /** - * Opaque tag Postmark stores alongside the message. We use it to - * distinguish creator_signup / creator_signin / vendor_signup / - * vendor_signin in dashboards and searches. - */ - tag?: string; -} - -export interface Mailer { - send(msg: MailMessage): Promise; -} - -class DevMailer implements Mailer { - async send(msg: MailMessage): Promise { - await db(TABLES.DevMessage).insert({ - id: ulid(), - to: msg.to, - subject: msg.subject, - body: msg.text, - html: msg.html ?? null, - metadata: (msg.metadata ?? {}) as never, - }); - console.log(`[dev-mail] to=${msg.to} subject="${msg.subject}"`); - } -} - -class PostmarkMailer implements Mailer { - constructor( - private readonly serverToken: string, - private readonly from: string, - private readonly messageStream: string, - ) {} - - async send(msg: MailMessage): Promise { - const res = await fetch('https://api.postmarkapp.com/email', { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - 'x-postmark-server-token': this.serverToken, - }, - body: JSON.stringify({ - From: this.from, - To: msg.to, - Subject: msg.subject, - TextBody: msg.text, - HtmlBody: msg.html, - MessageStream: this.messageStream, - Tag: msg.tag, - // Metadata values must be strings per Postmark's contract. - Metadata: msg.metadata - ? Object.fromEntries(Object.entries(msg.metadata).map(([k, v]) => [k, String(v)])) - : undefined, - }), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`postmark send failed: ${res.status} ${text.slice(0, 300)}`); - } - - // 200 with ErrorCode=0 is the success shape; ErrorCode != 0 is a - // per-message rejection (recipient suppressed, blocked, etc). Both - // are worth knowing about, but only ErrorCode != 0 with a non-zero - // status should throw. Postmark returns ErrorCode=0 on 200. - const body = (await res.json()) as { ErrorCode?: number; Message?: string }; - if (body.ErrorCode && body.ErrorCode !== 0) { - throw new Error(`postmark rejected message: ${body.ErrorCode} ${body.Message ?? ''}`); - } - } -} - -let mailerInstance: Mailer | null = null; - -export function getMailer(): Mailer { - if (mailerInstance) return mailerInstance; - const transport = process.env.MAIL_TRANSPORT ?? 'dev'; - if (transport === 'postmark') { - const token = process.env.POSTMARK_SERVER_TOKEN; - const from = process.env.MAIL_FROM; - if (!token) throw new Error('MAIL_TRANSPORT=postmark requires POSTMARK_SERVER_TOKEN'); - if (!from) throw new Error('MAIL_TRANSPORT=postmark requires MAIL_FROM'); - const stream = process.env.POSTMARK_MESSAGE_STREAM ?? 'outbound'; - mailerInstance = new PostmarkMailer(token, from, stream); - } else { - mailerInstance = new DevMailer(); - } - return mailerInstance; -} - -/** - * Reset for tests that want to change env vars between runs. Not used - * in production code paths. - */ -export function __resetMailerForTests(): void { - mailerInstance = null; -} diff --git a/apps/api/src/network/crypto.ts b/apps/api/src/network/crypto.ts deleted file mode 100644 index e4ff0d5..0000000 --- a/apps/api/src/network/crypto.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Envelope encryption for federation keys. - * - * Why: the Network needs to call out to a vendor's OpenPartner instance - * admin API on partnership approval. That means holding the plaintext key - * somewhere — a sha256 hash would be useless for outbound calls. - * - * We use AES-256-GCM with a master key pulled from NETWORK_ENCRYPTION_KEY - * (32 bytes, base64 or hex). In dev, if no key is set, we use a fixed - * dev-only key and log a warning — this is NEVER OK in production. The - * env-loader startup check enforces presence in production builds. - */ - -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; - -const ALG = 'aes-256-gcm'; -const IV_LEN = 12; // GCM recommends 12 bytes - -let cachedKey: Buffer | null = null; - -function masterKey(): Buffer { - if (cachedKey) return cachedKey; - const raw = process.env.NETWORK_ENCRYPTION_KEY; - if (!raw) { - if (process.env.NODE_ENV === 'production') { - throw new Error('NETWORK_ENCRYPTION_KEY is required in production'); - } - console.warn('[network.crypto] NETWORK_ENCRYPTION_KEY not set — using dev-only fallback. DO NOT USE IN PROD.'); - cachedKey = Buffer.alloc(32, 0x42); - return cachedKey; - } - // accept either hex or base64 - const buf = raw.length === 64 ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64'); - if (buf.length !== 32) throw new Error('NETWORK_ENCRYPTION_KEY must decode to exactly 32 bytes'); - cachedKey = buf; - return buf; -} - -export function encryptKey(plaintext: string): string { - const iv = randomBytes(IV_LEN); - const cipher = createCipheriv(ALG, masterKey(), iv); - const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - const tag = cipher.getAuthTag(); - return Buffer.concat([iv, tag, ct]).toString('base64'); -} - -export function decryptKey(envelope: string): string { - const buf = Buffer.from(envelope, 'base64'); - const iv = buf.subarray(0, IV_LEN); - const tag = buf.subarray(IV_LEN, IV_LEN + 16); - const ct = buf.subarray(IV_LEN + 16); - const decipher = createDecipheriv(ALG, masterKey(), iv); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); -} diff --git a/apps/api/src/network/federation.ts b/apps/api/src/network/federation.ts deleted file mode 100644 index 5df854b..0000000 --- a/apps/api/src/network/federation.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Federation client. - * - * When the Network approves a Partnership, we provision the actual - * Partner + Link on the vendor's OpenPartner instance — that's where - * attribution and payouts live. We call the vendor's admin API using the - * encrypted key the vendor supplied at registration. - * - * The client is resilient: if the vendor's instance is unreachable, the - * approval is rolled back so the creator isn't left with a half-provisioned - * partnership. (See network-requests.ts for the transaction boundary.) - */ - -import type { NetworkVendorRow, OfferingRow } from '@openpartner/db'; -import { decryptKey } from './crypto.js'; - -export interface FederationCreator { - name: string; - email: string; - handle: string; - promoCode?: string | null; -} - -export interface PartnerDashboardStats { - partnerId: string; - since: string; - clicks: number; - attributedEvents: number; - attributedRevenue: number; - commissionByStatus: Record; -} - -/** - * Read-side federation: pull a partner's dashboard off their vendor's - * OpenPartner instance. Used by the Network to surface per-partnership - * earnings to creators (and to vendors, inverted — "how much has this - * creator earned you?"). Attribution never leaves the vendor's instance; - * we just project it into the Network UI. - */ -export async function fetchPartnerDashboard( - vendor: NetworkVendorRow, - partnerId: string, -): Promise { - const key = decryptKey(vendor.instanceKeyCiphertext); - const res = await fetchJson(`${vendor.instanceUrl}/partners/${partnerId}/dashboard`, { - method: 'GET', - key, - }); - return res as unknown as PartnerDashboardStats; -} - -export interface PartnerCommission { - id: string; - partnerId: string; - amount: string; - currency: string; - status: 'accrued' | 'approved' | 'paid' | 'reversed' | (string & {}); - accruedAt: string; - paidAt: string | null; -} - -export async function fetchPartnerCommissions( - vendor: NetworkVendorRow, - partnerId: string, -): Promise { - const key = decryptKey(vendor.instanceKeyCiphertext); - const res = (await fetchJson(`${vendor.instanceUrl}/partners/${partnerId}/commissions?limit=500`, { - method: 'GET', - key, - })) as { commissions?: PartnerCommission[] }; - return res.commissions ?? []; -} - -export interface FederatedPartner { - partnerId: string; - linkKey: string; - publicShareUrl: string; - routerUrl: string; -} - -export async function provisionPartnerOnVendor(params: { - vendor: NetworkVendorRow; - offering: OfferingRow; - creator: FederationCreator; -}): Promise { - const { vendor, offering, creator } = params; - const key = decryptKey(vendor.instanceKeyCiphertext); - - const createPartnerRes = await fetchJson(`${vendor.instanceUrl}/partners`, { - method: 'POST', - key, - body: { - email: creator.email, - name: creator.name, - metadata: { source: 'openpartner_network', creatorHandle: creator.handle }, - }, - }); - - const partnerId = String(createPartnerRes.id); - - // Preferred link key order: request-level promoCode → creator handle. The - // slug is what appears in the share URL (e.g. getcoherence.io/r/), - // so we respect whatever the creator chose up-front. If uniqueness - // collides on the vendor's instance, fetchJsonWithFallback retries with - // a short suffix so the creator still gets something close to what they - // picked instead of the provisioning failing outright. - const linkKey = sanitizeLinkKey(creator.promoCode || creator.handle); - const linkPayload = { - linkKey, - campaignId: offering.vendorCampaignId, - destinationUrl: offering.productUrl, - }; - - const linkRes = await fetchJsonWithFallback(`${vendor.instanceUrl}/partners/${partnerId}/links`, { - method: 'POST', - key, - body: linkPayload, - fallbackBody: { ...linkPayload, linkKey: `${linkKey}-${partnerId.slice(-6).toLowerCase()}` }, - }); - - // Router URL is co-deployed with the vendor's OpenPartner. Convention: - // swap the API host's default port (4601) for the router's (4701), or - // honor a routerUrl override we could add to NetworkVendor later. - const routerUrl = deriveRouterUrl(vendor); - const actualLinkKey = String(linkRes.linkKey); - const publicShareUrl = `${routerUrl}/r/${actualLinkKey}`; - - return { partnerId, linkKey: actualLinkKey, publicShareUrl, routerUrl }; -} - -function sanitizeLinkKey(raw: string): string { - const cleaned = raw - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '_') - .replace(/^_+|_+$/g, '') - .slice(0, 40); - return cleaned || 'creator'; -} - -function deriveRouterUrl(vendor: NetworkVendorRow): string { - // Priority: explicit NetworkVendor.routerUrl → env override → port-swap - // convention (API 4601 → router 4701) for localhost dev. Production - // vendors should set routerUrl to their branded apex (e.g. - // https://getcoherence.io) so share URLs land at the right hostname. - if (vendor.routerUrl) return vendor.routerUrl; - const env = process.env.NETWORK_ROUTER_URL; - if (env) return env; - try { - const url = new URL(vendor.instanceUrl); - if (url.port === '4601') { - url.port = '4701'; - return url.origin; - } - } catch { - /* ignore */ - } - return vendor.instanceUrl; -} - -interface FetchParams { - method: 'POST' | 'GET'; - key: string; - body?: unknown; -} - -async function fetchJson(url: string, params: FetchParams): Promise> { - const res = await fetch(url, { - method: params.method, - headers: { - authorization: `Bearer ${params.key}`, - 'content-type': 'application/json', - }, - body: params.body !== undefined ? JSON.stringify(params.body) : undefined, - }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`${params.method} ${url} → ${res.status}: ${text.slice(0, 300)}`); - } - return text ? (JSON.parse(text) as Record) : {}; -} - -async function fetchJsonWithFallback( - url: string, - params: FetchParams & { fallbackBody: unknown }, -): Promise> { - try { - return await fetchJson(url, { method: params.method, key: params.key, body: params.body }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('409') || msg.includes('linkKey_taken')) { - return fetchJson(url, { method: params.method, key: params.key, body: params.fallbackBody }); - } - throw err; - } -} diff --git a/apps/api/src/network/safe-fetch.ts b/apps/api/src/network/safe-fetch.ts deleted file mode 100644 index 87564d3..0000000 --- a/apps/api/src/network/safe-fetch.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * SSRF-safe outbound fetch to user-provided URLs. - * - * The Network flow accepts a vendor's `instanceUrl` unauthenticated — a - * prospective vendor is still signing up and doesn't have an account - * yet. That means we can't fix the SSRF surface with auth; we have to - * validate the URL itself. Defence in depth: - * - * 1. Only http:// or https:// (no file:, gopher:, etc.) - * 2. Resolve DNS; reject if ANY resolved address lives in a private - * / loopback / link-local / cgnat range. All addresses must be - * public because an attacker who controls DNS can rebind between - * this check and fetch(), but matching hostname-lookup-then-fetch - * with a Node agent that dials only the checked IPs is beyond - * v1 scope — the loud-default rejection here closes 95% of - * exploits in the wild. - * 3. Self-hosters running everything on a VPN or private network can - * opt out by setting NETWORK_ALLOW_PRIVATE_HOSTS=1. - * - * Still returns a standard Response — the caller pipes through to - * their existing logic. - */ - -import { lookup } from 'node:dns/promises'; -import { isIP } from 'node:net'; - -const PRIVATE_V4 = [ - /^0\./, - /^10\./, - /^127\./, - /^169\.254\./, - /^172\.(1[6-9]|2\d|3[01])\./, - /^192\.168\./, - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT 100.64.0.0/10 -]; - -function isPrivateAddress(addr: string): boolean { - const lower = addr.toLowerCase(); - if (lower === '::1' || lower === '::' || lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) { - return true; - } - // IPv4-mapped IPv6 - const mapped = lower.match(/^::ffff:([\d.]+)$/); - if (mapped && mapped[1]) return PRIVATE_V4.some((re) => re.test(mapped[1]!)); - return PRIVATE_V4.some((re) => re.test(lower)); -} - -async function assertPublicHost(host: string): Promise { - if (process.env.NETWORK_ALLOW_PRIVATE_HOSTS === '1') return; - // Tests spin up loopback vendor instances on ephemeral ports. The - // bypass only triggers under NODE_ENV=test (vitest default) — not a - // knob a deployed instance can flip accidentally. - if (process.env.NODE_ENV === 'test') return; - - // WHATWG URL hostname for IPv6 keeps the [brackets] — strip before - // the isIP / private-address check, otherwise "[::1]" falls through - // to DNS and the guard misses loopback. - const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; - - if (isIP(bare) !== 0) { - if (isPrivateAddress(bare)) { - throw Object.assign(new Error('private_host_blocked'), { code: 'private_host_blocked' }); - } - return; - } - - const records = await lookup(host, { all: true }); - if (records.length === 0) { - throw Object.assign(new Error('dns_no_records'), { code: 'dns_no_records' }); - } - for (const r of records) { - if (isPrivateAddress(r.address)) { - throw Object.assign(new Error('private_host_blocked'), { code: 'private_host_blocked' }); - } - } -} - -export async function safeFetch(urlString: string, init: RequestInit = {}): Promise { - const url = new URL(urlString); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw Object.assign(new Error('unsupported_protocol'), { code: 'unsupported_protocol' }); - } - await assertPublicHost(url.hostname); - - return fetch(url, { - ...init, - // 10s ceiling — enough for a slow TLS handshake on a distant box, - // short enough that an attacker can't use us as a long-tail probe. - signal: AbortSignal.timeout(10_000), - }); -} diff --git a/apps/api/src/network/validation.ts b/apps/api/src/network/validation.ts deleted file mode 100644 index bb4f6a1..0000000 --- a/apps/api/src/network/validation.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { z } from 'zod'; - -export const platformSchema = z.object({ - platform: z.enum(['youtube', 'twitter', 'instagram', 'tiktok', 'blog', 'podcast', 'other']), - url: z.string().url(), - followers: z.number().int().nonnegative().optional(), -}); - -export const payoutSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('recurring_percent'), - percent: z.number().positive().max(100), - durationMonths: z.number().int().positive().nullable(), - }), - z.object({ - type: z.literal('one_time_fee'), - amount: z.number().positive(), - currency: z.string().length(3).optional(), - }), - z.object({ - type: z.literal('tiered_percent'), - tiers: z - .array(z.object({ minRevenueUsd: z.number().nonnegative(), percent: z.number().positive().max(100) })) - .min(1), - }), -]); - -export const bonusSchema = z.object({ - description: z.string().min(1), - triggerRevenueUsd: z.number().positive(), - bonusUsd: z.number().positive(), -}); - -export const termsSchema = z.object({ - payout: payoutSchema, - bonuses: z.array(bonusSchema).optional(), - cookieWindowDays: z.number().int().min(1).max(365), - exclusions: z.array(z.string()).optional(), -}); - -// Same shape as vendor-side Link.linkKey — URL-safe, 3–40 chars. Applies -// to both creator.defaultPromoCode and request.promoCode. -export const promoCodeSchema = z - .string() - .min(3) - .max(40) - .regex(/^[a-zA-Z0-9_-]+$/, 'promo code must be url-safe (letters, digits, _ or -)'); - -export const vendorCreateSchema = z.object({ - name: z.string().min(2), - slug: z - .string() - .min(2) - .max(40) - .regex(/^[a-z0-9][a-z0-9-]*$/), - email: z.string().email().optional(), - websiteUrl: z.string().url().optional(), - logoUrl: z.string().url().optional(), - description: z.string().max(1000).optional(), - instanceUrl: z.string().url(), - instanceKey: z.string().min(8), // the admin key on the vendor's instance - // Optional for dev (we fall back to the port-swap convention); required - // in practice for production so share URLs resolve at the vendor's apex. - routerUrl: z.string().url().optional(), -}); - -export const creatorCreateSchema = z.object({ - name: z.string().min(2), - handle: z - .string() - .min(2) - .max(40) - .regex(/^[a-z0-9_]+$/), - email: z.string().email(), - bio: z.string().max(2000).optional(), - avatarUrl: z.string().url().optional(), - platforms: z.array(platformSchema).optional(), - defaultPromoCode: promoCodeSchema.optional(), -}); - -// PATCH — every field optional; empty string clears the value, missing -// keys mean "leave unchanged." Handle + email NOT editable: handle change -// invalidates partnership linkKeys on every vendor instance simultaneously -// and email is the magic-link identity. -export const creatorUpdateSchema = z.object({ - name: z.string().min(2).max(80).optional(), - bio: z.string().max(2000).nullable().optional(), - avatarUrl: z.string().url().nullable().optional(), - platforms: z.array(platformSchema).optional(), - defaultPromoCode: promoCodeSchema.nullable().optional(), -}); - -export const offeringCreateSchema = z.object({ - title: z.string().min(2).max(120), - productUrl: z.string().url(), - description: z.string().max(4000).optional(), - heroImageUrl: z.string().url().optional(), - vendorCampaignId: z.string().min(1), - terms: termsSchema, - published: z.boolean().optional(), -}); - -export const offeringUpdateSchema = offeringCreateSchema.partial(); - -export const requestCreateSchema = z.object({ - offeringId: z.string().min(1), - message: z.string().max(2000).optional(), - promoCode: promoCodeSchema.optional(), -}); - -export const requestDecideSchema = z.object({ - decisionNote: z.string().max(2000).optional(), -}); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index e2f5e9f..b761442 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { TABLES, type NetworkCreatorRow, type NetworkVendorRow, type PartnerRow } from '@openpartner/db'; +import { TABLES, type PartnerRow } from '@openpartner/db'; import { db } from '../db.js'; import { requireAuth } from '../auth.js'; @@ -9,10 +9,6 @@ export const authRouter = Router(); * Reports the calling key's permission set so upstream integrations (like * the OpenPartner Network) can verify the key they've been handed actually * has the scopes they need — and loudly warn if it's unrestricted. - * - * scoped key → { role: 'scoped', scopes: [...] } - * admin / env → { role: 'admin', unrestricted: true } - * partner / vendor → { role, restrictedTo: ... } */ authRouter.get('/auth/introspect', requireAuth, async (req, res) => { const p = req.principal!; @@ -25,18 +21,11 @@ authRouter.get('/auth/introspect', requireAuth, async (req, res) => { if (p.role === 'partner') { return res.json({ role: 'partner', restrictedTo: { partnerId: p.partnerId } }); } - if (p.role === 'network_vendor') { - return res.json({ role: 'network_vendor', restrictedTo: { networkVendorId: p.networkVendorId } }); - } - if (p.role === 'network_creator') { - return res.json({ role: 'network_creator', restrictedTo: { networkCreatorId: p.networkCreatorId } }); - } }); /** * Returns the caller's principal shape — used by the portal to decide what - * to render after login. Surfaces the Network role when the key belongs to - * a vendor or creator so the portal can route to /network views. + * to render after login. */ authRouter.get('/auth/whoami', requireAuth, async (req, res) => { const p = req.principal!; @@ -53,39 +42,6 @@ authRouter.get('/auth/whoami', requireAuth, async (req, res) => { : null, }); } - if (p.role === 'network_vendor') { - const vendor = await db(TABLES.NetworkVendor).where({ id: p.networkVendorId }).first(); - return res.json({ - role: 'network_vendor', - networkVendorId: p.networkVendorId, - vendor: vendor - ? { - id: vendor.id, - name: vendor.name, - slug: vendor.slug, - logoUrl: vendor.logoUrl, - websiteUrl: vendor.websiteUrl, - status: vendor.status, - } - : null, - }); - } - if (p.role === 'network_creator') { - const creator = await db(TABLES.NetworkCreator).where({ id: p.networkCreatorId }).first(); - return res.json({ - role: 'network_creator', - networkCreatorId: p.networkCreatorId, - creator: creator - ? { - id: creator.id, - name: creator.name, - handle: creator.handle, - email: creator.email, - avatarUrl: creator.avatarUrl, - defaultPromoCode: creator.defaultPromoCode, - status: creator.status, - } - : null, - }); - } + // Scoped keys used by federation clients don't need a human-facing whoami. + res.json({ role: p.role }); }); diff --git a/apps/api/src/routes/magic-link.ts b/apps/api/src/routes/magic-link.ts deleted file mode 100644 index f167b55..0000000 --- a/apps/api/src/routes/magic-link.ts +++ /dev/null @@ -1,468 +0,0 @@ -/** - * Human-auth endpoints — magic-link signup/signin for creators AND - * vendors, plus dev mailbox. - * - * Purpose strings encode BOTH the role (creator / vendor) and the - * lifecycle stage (signup / signin), giving us four values: - * creator_signup — claim carries handle + name → creates active NetworkCreator - * creator_signin — returning active creator → new session - * vendor_signup — claim carries full vendor profile → creates pending NetworkVendor - * vendor_signin — returning active vendor → new session - * - * We deliberately use 'pending' status for vendor signup so an admin - * still reviews the federation credentials before activating — unlike - * creator signup where magic-link email verification is enough. - * - * Token consumption is single-use and atomic (conditional update on - * consumedAt IS NULL). Tokens expire after 15 minutes. - */ - -import { Router } from 'express'; -import { z } from 'zod'; -import { ulid } from 'ulid'; -import { - TABLES, - type DevMessageRow, - type MagicLinkCreatorClaim, - type MagicLinkTokenRow, - type MagicLinkVendorClaim, - type NetworkCreatorRow, - type NetworkVendorRow, -} from '@openpartner/db'; -import { db } from '../db.js'; -import { requireAdmin, requireAuth } from '../auth.js'; -import { getMailer } from '../mailer.js'; -import { - SESSION_COOKIE_NAME, - consumeMagicLink, - createSession, - issueMagicLink, - revokeSession, - sessionCookieOptions, -} from '../auth-sessions.js'; -import { encryptKey } from '../network/crypto.js'; -import { safeFetch } from '../network/safe-fetch.js'; -import { - creatorSigninEmail, - creatorSignupEmail, - vendorSigninEmail, - vendorSignupEmail, -} from '../email-templates.js'; -import { NETWORK_FEDERATION_SCOPES } from './api-keys.js'; -import { ipRateLimit } from '../middleware/rate-limit.js'; - -export const magicLinkRouter = Router(); - -// Shared bucket across every email-triggering auth endpoint — stops an -// attacker from rotating across /creator/signin, /vendor/signin, etc. to -// multiply the cap. 10/min per IP is loose for one real user, tight for -// a bot. -const mailAuthLimit = ipRateLimit({ name: 'magic-link-mail', max: 10, windowMs: 60_000 }); - -// Token verification is single-use already, but brute-forcing /verify -// across many IPs is still a theoretical risk. Modest cap — a legit -// user verifies once. -const verifyLimit = ipRateLimit({ name: 'magic-link-verify', max: 30, windowMs: 60_000 }); - -const creatorSignupSchema = z.object({ - email: z.string().email(), - handle: z - .string() - .min(2) - .max(40) - .regex(/^[a-z0-9_]+$/, 'handle must be lowercase letters, digits, or _'), - name: z.string().min(2).max(80), -}); - -const vendorSignupSchema = z.object({ - email: z.string().email(), - name: z.string().min(2).max(120), - slug: z - .string() - .min(2) - .max(40) - .regex(/^[a-z0-9][a-z0-9-]*$/, 'slug must be lowercase letters, digits, or -'), - instanceUrl: z.string().url(), - instanceKey: z.string().min(8), - routerUrl: z.string().url().optional(), - description: z.string().max(1000).optional(), - websiteUrl: z.string().url().optional(), - logoUrl: z.string().url().optional(), -}); - -const signinSchema = z.object({ email: z.string().email() }); -const verifySchema = z.object({ token: z.string().min(8) }); - -function portalOrigin(): string { - return (process.env.PORTAL_URL ?? 'http://localhost:5673').replace(/\/$/, ''); -} - -function magicUrl(token: string, purpose: string): string { - return `${portalOrigin()}/auth/magic?token=${encodeURIComponent(token)}&purpose=${purpose}`; -} - -// -------- Creator signup -------- - -magicLinkRouter.post('/auth/creator/signup', mailAuthLimit, async (req, res) => { - const body = creatorSignupSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const email = body.data.email.toLowerCase(); - const handle = body.data.handle.toLowerCase(); - - const existing = await db(TABLES.NetworkCreator) - .where({ email }) - .orWhere({ handle }) - .first(); - if (existing) return res.status(409).json({ error: 'email_or_handle_taken' }); - - const claim: MagicLinkCreatorClaim = { kind: 'creator', handle, name: body.data.name }; - const issued = await issueMagicLink({ email, purpose: 'creator_signup', claim }); - - const tmpl = creatorSignupEmail(body.data.name, magicUrl(issued.plaintext, 'creator_signup')); - await getMailer().send({ - to: email, - subject: tmpl.subject, - text: tmpl.text, - html: tmpl.html, - tag: tmpl.tag, - metadata: { purpose: 'creator_signup', handle }, - }); - - res.json({ ok: true }); -}); - -// -------- Vendor signup -------- -// -// We verify the vendor's scoped API key against their own instance BEFORE -// issuing the magic link — no point emailing them a verification link -// only to fail at admin-approval time because the key doesn't work. - -magicLinkRouter.post('/auth/vendor/signup', mailAuthLimit, async (req, res) => { - const body = vendorSignupSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const email = body.data.email.toLowerCase(); - - const existing = await db(TABLES.NetworkVendor).where({ slug: body.data.slug }).first(); - if (existing) return res.status(409).json({ error: 'slug_taken' }); - - // Probe the instance's /auth/introspect with the pasted key. Reject if - // the key can't reach the instance or doesn't have the federation - // scopes (unrestricted admin keys are accepted but flagged in the UI). - const introspectUrl = `${body.data.instanceUrl.replace(/\/$/, '')}/auth/introspect`; - try { - const response = await safeFetch(introspectUrl, { - headers: { authorization: `Bearer ${body.data.instanceKey}` }, - }); - if (!response.ok) { - const text = await response.text(); - return res.status(400).json({ - error: 'instance_rejected_key', - status: response.status, - detail: text.slice(0, 300), - }); - } - const intro = (await response.json()) as Record; - const scopes = Array.isArray(intro.scopes) ? (intro.scopes as string[]) : null; - const unrestricted = intro.role === 'admin' && intro.unrestricted === true; - const missing = - scopes != null - ? (NETWORK_FEDERATION_SCOPES as readonly string[]).filter((s) => !scopes.includes(s)) - : []; - if (!unrestricted && (scopes == null || missing.length > 0)) { - return res.status(400).json({ - error: 'missing_scopes', - missing, - have: scopes ?? [], - }); - } - } catch (err: unknown) { - return res.status(400).json({ - error: 'instance_unreachable', - detail: err instanceof Error ? err.message : String(err), - }); - } - - const claim: MagicLinkVendorClaim = { - kind: 'vendor', - name: body.data.name, - slug: body.data.slug, - instanceUrl: body.data.instanceUrl.replace(/\/$/, ''), - instanceKeyCiphertext: encryptKey(body.data.instanceKey), - instanceKeyPrefix: body.data.instanceKey.slice(0, 8), - ...(body.data.routerUrl ? { routerUrl: body.data.routerUrl } : {}), - ...(body.data.description ? { description: body.data.description } : {}), - ...(body.data.websiteUrl ? { websiteUrl: body.data.websiteUrl } : {}), - ...(body.data.logoUrl ? { logoUrl: body.data.logoUrl } : {}), - }; - const issued = await issueMagicLink({ email, purpose: 'vendor_signup', claim }); - - const tmpl = vendorSignupEmail(body.data.name, magicUrl(issued.plaintext, 'vendor_signup')); - await getMailer().send({ - to: email, - subject: tmpl.subject, - text: tmpl.text, - html: tmpl.html, - tag: tmpl.tag, - metadata: { purpose: 'vendor_signup', slug: body.data.slug }, - }); - - res.json({ ok: true }); -}); - -// -------- Unified signin -------- -// -// One endpoint for humans. Looks up creator first, then vendor; issues a -// link for whichever role matches. Response is identical regardless of -// which (or neither) matches, so the endpoint doesn't leak whether an -// email is registered on the Network. - -magicLinkRouter.post('/auth/signin', mailAuthLimit, async (req, res) => { - const body = signinSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - const email = body.data.email.toLowerCase(); - - const creator = await db(TABLES.NetworkCreator).where({ email }).first(); - if (creator && creator.status === 'active') { - const issued = await issueMagicLink({ email, purpose: 'creator_signin' }); - const tmpl = creatorSigninEmail(magicUrl(issued.plaintext, 'creator_signin')); - await getMailer().send({ - to: email, - subject: tmpl.subject, - text: tmpl.text, - html: tmpl.html, - tag: tmpl.tag, - metadata: { purpose: 'creator_signin' }, - }); - return res.json({ ok: true }); - } - - // Vendors use email too; we need a way to tie vendors to an email. For - // now we assume vendor.description or a dedicated column — but we don't - // have vendor.email yet. We infer via MagicLinkToken history: find the - // most recent consumed vendor_signup token for this email and look up - // the vendor created from it. That keeps migrations light for MVP. - const vendor = await findVendorByEmail(email); - if (vendor && vendor.status === 'active') { - const issued = await issueMagicLink({ email, purpose: 'vendor_signin' }); - const tmpl = vendorSigninEmail(magicUrl(issued.plaintext, 'vendor_signin')); - await getMailer().send({ - to: email, - subject: tmpl.subject, - text: tmpl.text, - html: tmpl.html, - tag: tmpl.tag, - metadata: { purpose: 'vendor_signin', vendorId: vendor.id }, - }); - } - - // No-op on unknown / inactive — don't reveal which. - res.json({ ok: true }); -}); - -// Deprecated alias for older clients still calling /auth/creator/signin. -// Scoped to creators only — matches the pre-unified contract. -magicLinkRouter.post('/auth/creator/signin', mailAuthLimit, async (req, res) => { - const body = signinSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - const email = body.data.email.toLowerCase(); - - const creator = await db(TABLES.NetworkCreator).where({ email }).first(); - if (creator && creator.status === 'active') { - const issued = await issueMagicLink({ email, purpose: 'creator_signin' }); - const tmpl = creatorSigninEmail(magicUrl(issued.plaintext, 'creator_signin')); - await getMailer().send({ - to: email, - subject: tmpl.subject, - text: tmpl.text, - html: tmpl.html, - tag: tmpl.tag, - metadata: { purpose: 'creator_signin' }, - }); - } - res.json({ ok: true }); -}); - -// -------- Verify -------- - -magicLinkRouter.post('/auth/magic/verify', verifyLimit, async (req, res) => { - const body = verifySchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const result = await consumeMagicLink(body.data.token); - if (!result.ok) return res.status(400).json({ error: result.error }); - - const token: MagicLinkTokenRow = result.token; - - if (token.purpose === 'creator_signup') { - return verifyCreatorSignup(token, res); - } - if (token.purpose === 'creator_signin') { - return verifyCreatorSignin(token, res); - } - if (token.purpose === 'vendor_signup') { - return verifyVendorSignup(token, res); - } - if (token.purpose === 'vendor_signin') { - return verifyVendorSignin(token, res); - } - res.status(400).json({ error: 'unknown_purpose' }); -}); - -async function verifyCreatorSignup(token: MagicLinkTokenRow, res: Parameters[1]>[1]) { - const claim = token.claim; - if (!claim || claim.kind !== 'creator') { - return res.status(400).json({ error: 'invalid_signup_claim' }); - } - - const collision = await db(TABLES.NetworkCreator) - .where({ email: token.email }) - .orWhere({ handle: claim.handle }) - .first(); - if (collision) return res.status(409).json({ error: 'email_or_handle_taken' }); - - const id = ulid(); - await db(TABLES.NetworkCreator).insert({ - id, - name: claim.name, - handle: claim.handle, - email: token.email, - bio: null, - avatarUrl: null, - platforms: JSON.stringify([]) as unknown as never, - defaultPromoCode: null, - status: 'active', - activatedAt: new Date(), - }); - const creator = (await db(TABLES.NetworkCreator).where({ id }).first())!; - - const session = await createSession({ principalKind: 'network_creator', principalId: creator.id }); - res.cookie(SESSION_COOKIE_NAME, session.plaintext, sessionCookieOptions()); - res.json({ - ok: true, - role: 'network_creator', - creator: { - id: creator.id, - name: creator.name, - handle: creator.handle, - email: creator.email, - avatarUrl: creator.avatarUrl, - defaultPromoCode: creator.defaultPromoCode, - status: creator.status, - }, - }); -} - -async function verifyCreatorSignin(token: MagicLinkTokenRow, res: Parameters[1]>[1]) { - const creator = await db(TABLES.NetworkCreator).where({ email: token.email }).first(); - if (!creator) return res.status(404).json({ error: 'creator_not_found' }); - if (creator.status !== 'active') return res.status(403).json({ error: 'creator_not_active' }); - - const session = await createSession({ principalKind: 'network_creator', principalId: creator.id }); - res.cookie(SESSION_COOKIE_NAME, session.plaintext, sessionCookieOptions()); - res.json({ - ok: true, - role: 'network_creator', - creator: { - id: creator.id, - name: creator.name, - handle: creator.handle, - email: creator.email, - avatarUrl: creator.avatarUrl, - defaultPromoCode: creator.defaultPromoCode, - status: creator.status, - }, - }); -} - -async function verifyVendorSignup(token: MagicLinkTokenRow, res: Parameters[1]>[1]) { - const claim = token.claim; - if (!claim || claim.kind !== 'vendor') { - return res.status(400).json({ error: 'invalid_signup_claim' }); - } - - const collision = await db(TABLES.NetworkVendor).where({ slug: claim.slug }).first(); - if (collision) return res.status(409).json({ error: 'slug_taken' }); - - const id = ulid(); - await db(TABLES.NetworkVendor).insert({ - id, - name: claim.name, - slug: claim.slug, - email: token.email, - websiteUrl: claim.websiteUrl ?? null, - logoUrl: claim.logoUrl ?? null, - description: claim.description ?? null, - instanceUrl: claim.instanceUrl, - // claim carries the ciphertext already — no round-trip through plaintext - instanceKeyCiphertext: claim.instanceKeyCiphertext, - instanceKeyPrefix: claim.instanceKeyPrefix, - routerUrl: claim.routerUrl ?? null, - status: 'pending', // admin still reviews the federation relationship - }); - - // No session yet — the vendor is pending. Returning a helpful message - // so the portal can show "admin is reviewing your application." - res.json({ - ok: true, - role: 'network_vendor', - status: 'pending', - vendor: { id, name: claim.name, slug: claim.slug }, - }); -} - -async function verifyVendorSignin(token: MagicLinkTokenRow, res: Parameters[1]>[1]) { - const vendor = await findVendorByEmail(token.email); - if (!vendor) return res.status(404).json({ error: 'vendor_not_found' }); - if (vendor.status !== 'active') return res.status(403).json({ error: 'vendor_not_active' }); - - const session = await createSession({ principalKind: 'network_vendor', principalId: vendor.id }); - res.cookie(SESSION_COOKIE_NAME, session.plaintext, sessionCookieOptions()); - res.json({ - ok: true, - role: 'network_vendor', - vendor: { - id: vendor.id, - name: vendor.name, - slug: vendor.slug, - logoUrl: vendor.logoUrl, - websiteUrl: vendor.websiteUrl, - status: vendor.status, - }, - }); -} - -/** - * Look up the vendor tied to a signup email. One email can theoretically - * own multiple vendors — we pick the most recently created active one - * and fall through to `vendor_not_active` if nothing is active. Callers - * that need to disambiguate between several active vendors should - * collect the slug from the user first. - */ -async function findVendorByEmail(email: string): Promise { - const vendors = await db(TABLES.NetworkVendor) - .where({ email: email.toLowerCase() }) - .orderBy('createdAt', 'desc'); - return vendors.find((v) => v.status === 'active') ?? vendors[0]; -} - -// -------- Signout -------- - -magicLinkRouter.post('/auth/signout', async (req, res) => { - const plaintext = req.cookies?.[SESSION_COOKIE_NAME]; - if (plaintext) { - const { resolveSession } = await import('../auth-sessions.js'); - const session = await resolveSession(plaintext); - if (session) await revokeSession(session.id); - } - res.clearCookie(SESSION_COOKIE_NAME, { path: '/' }); - res.json({ ok: true }); -}); - -// -------- Dev mailbox -------- - -magicLinkRouter.get('/dev/mailbox', requireAuth, requireAdmin, async (_req, res) => { - const messages = await db(TABLES.DevMessage).orderBy('createdAt', 'desc').limit(100); - res.json({ messages }); -}); diff --git a/apps/api/src/routes/network-creators.ts b/apps/api/src/routes/network-creators.ts deleted file mode 100644 index ff487e9..0000000 --- a/apps/api/src/routes/network-creators.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Router } from 'express'; -import { ulid } from 'ulid'; -import { TABLES, type NetworkCreatorRow } from '@openpartner/db'; -import { db } from '../db.js'; -import { createApiKeyRow, requireAdmin, requireAuth, requireNetworkCreator } from '../auth.js'; -import { creatorCreateSchema, creatorUpdateSchema } from '../network/validation.js'; - -export const networkCreatorsRouter = Router(); - -// Admin: list + activate. In a real production world this would be -// self-serve with email verification; MVP keeps a moderation queue. -networkCreatorsRouter.get('/network/creators', requireAuth, requireAdmin, async (_req, res) => { - const creators = await db(TABLES.NetworkCreator).orderBy('createdAt', 'desc'); - res.json({ creators }); -}); - -networkCreatorsRouter.post('/network/creators', requireAuth, requireAdmin, async (req, res) => { - const body = creatorCreateSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const id = ulid(); - try { - await db(TABLES.NetworkCreator).insert({ - id, - name: body.data.name, - handle: body.data.handle, - email: body.data.email, - bio: body.data.bio ?? null, - avatarUrl: body.data.avatarUrl ?? null, - platforms: JSON.stringify(body.data.platforms ?? []) as unknown as never, // jsonb - defaultPromoCode: body.data.defaultPromoCode ?? null, - status: 'pending', - }); - } catch (err: unknown) { - if (typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505') { - return res.status(409).json({ error: 'handle_or_email_taken' }); - } - throw err; - } - - const key = await createApiKeyRow({ networkCreatorId: id, label: 'creator portal' }); - const creator = await db(TABLES.NetworkCreator).where({ id }).first(); - res.status(201).json({ creator, apiKey: key.plaintext }); -}); - -networkCreatorsRouter.post('/network/creators/:id/activate', requireAuth, requireAdmin, async (req, res) => { - const updated = await db(TABLES.NetworkCreator) - .where({ id: req.params.id }) - .update({ status: 'active', activatedAt: new Date() }) - .returning('*'); - if (updated.length === 0) return res.status(404).json({ error: 'creator_not_found' }); - res.json({ creator: updated[0] }); -}); - -// Creator self-view (own profile) — already in /auth/whoami but this is -// the canonical profile endpoint. -networkCreatorsRouter.get('/network/creators/me', requireAuth, requireNetworkCreator, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_creator') return res.status(403).json({ error: 'forbidden' }); - const creator = await db(TABLES.NetworkCreator).where({ id: p.networkCreatorId }).first(); - if (!creator) return res.status(404).json({ error: 'creator_not_found' }); - res.json({ creator }); -}); - -// Creator self-edit. Handle + email are intentionally NOT patchable: -// changing handle breaks share-URL references on vendor instances, and -// email is the magic-link identity. -networkCreatorsRouter.patch('/network/creators/me', requireAuth, requireNetworkCreator, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_creator') return res.status(403).json({ error: 'forbidden' }); - - const body = creatorUpdateSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const patch: Record = {}; - if (body.data.name !== undefined) patch.name = body.data.name; - if (body.data.bio !== undefined) patch.bio = body.data.bio; - if (body.data.avatarUrl !== undefined) patch.avatarUrl = body.data.avatarUrl; - if (body.data.defaultPromoCode !== undefined) patch.defaultPromoCode = body.data.defaultPromoCode; - if (body.data.platforms !== undefined) patch.platforms = JSON.stringify(body.data.platforms); - - if (Object.keys(patch).length === 0) { - const current = await db(TABLES.NetworkCreator).where({ id: p.networkCreatorId }).first(); - return res.json({ creator: current }); - } - - const [updated] = await db(TABLES.NetworkCreator) - .where({ id: p.networkCreatorId }) - .update(patch) - .returning('*'); - res.json({ creator: updated }); -}); - -// -------- Public directory: active creators (for vendors to browse) -------- - -networkCreatorsRouter.get('/network/directory/creators', async (_req, res) => { - const creators = await db(TABLES.NetworkCreator) - .where({ status: 'active' }) - .orderBy('createdAt', 'desc') - .select('id', 'name', 'handle', 'bio', 'avatarUrl', 'platforms', 'createdAt'); - res.json({ creators }); -}); diff --git a/apps/api/src/routes/network-earnings.ts b/apps/api/src/routes/network-earnings.ts deleted file mode 100644 index c3ab208..0000000 --- a/apps/api/src/routes/network-earnings.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Federated earnings view. - * - * For each active Partnership visible to the principal, we call the vendor's - * /partners/:id/dashboard (via stored admin key) and project the stats back - * into the Network UI. Attribution data stays on the vendor's instance — - * this is a read-only projection. - * - * Fan-out uses Promise.allSettled so a single unreachable vendor doesn't - * black out the whole page. Each partnership ships back with a status: - * ok — stats populated - * error — stats zeroed, `error` message set - * - * We group by vendorId first so we only decrypt each vendor's key once per - * request even if the creator has multiple partnerships with the same vendor. - */ - -import { Router } from 'express'; -import { - TABLES, - type NetworkVendorRow, - type OfferingRow, - type PartnershipRow, -} from '@openpartner/db'; -import { db } from '../db.js'; -import { requireAuth } from '../auth.js'; -import { fetchPartnerCommissions, fetchPartnerDashboard, type PartnerDashboardStats } from '../network/federation.js'; - -export const networkEarningsRouter = Router(); - -interface PartnershipEarning { - partnership: { - id: string; - vendorId: string; - vendorName: string; - offeringTitle: string; - vendorLinkKey: string; - publicShareUrl: string; - createdAt: string; - }; - status: 'ok' | 'error'; - error?: string; - stats: PartnerDashboardStats | null; -} - -networkEarningsRouter.get('/network/partnerships/earnings', requireAuth, async (req, res) => { - const p = req.principal!; - - const partnershipQuery = db(TABLES.Partnership).where({ status: 'active' }); - if (p.role === 'network_creator') partnershipQuery.andWhere({ creatorId: p.networkCreatorId }); - else if (p.role === 'network_vendor') partnershipQuery.andWhere({ vendorId: p.networkVendorId }); - else if (p.role !== 'admin') return res.status(403).json({ error: 'forbidden' }); - - const partnerships = await partnershipQuery.orderBy('createdAt', 'desc'); - if (partnerships.length === 0) { - return res.json({ partnerships: [], totals: emptyTotals() }); - } - - const vendorIds = Array.from(new Set(partnerships.map((p) => p.vendorId))); - const offeringIds = Array.from(new Set(partnerships.map((p) => p.offeringId))); - const [vendors, offerings] = await Promise.all([ - db(TABLES.NetworkVendor).whereIn('id', vendorIds), - db(TABLES.Offering).whereIn('id', offeringIds), - ]); - const vendorById = new Map(vendors.map((v) => [v.id, v])); - const offeringById = new Map(offerings.map((o) => [o.id, o])); - - const results = await Promise.all( - partnerships.map(async (pRow): Promise => { - const vendor = vendorById.get(pRow.vendorId); - const offering = offeringById.get(pRow.offeringId); - const base = { - id: pRow.id, - vendorId: pRow.vendorId, - vendorName: vendor?.name ?? 'Unknown vendor', - offeringTitle: offering?.title ?? 'Unknown offering', - vendorLinkKey: pRow.vendorLinkKey, - publicShareUrl: pRow.publicShareUrl, - createdAt: pRow.createdAt instanceof Date ? pRow.createdAt.toISOString() : String(pRow.createdAt), - }; - - if (!vendor) { - return { partnership: base, status: 'error', error: 'vendor_missing', stats: null }; - } - - try { - const stats = await fetchPartnerDashboard(vendor, pRow.vendorPartnerId); - return { partnership: base, status: 'ok', stats }; - } catch (err: unknown) { - return { - partnership: base, - status: 'error', - error: err instanceof Error ? err.message : String(err), - stats: null, - }; - } - }), - ); - - res.json({ - partnerships: results, - totals: computeTotals(results), - }); -}); - -// -------- Per-partnership commission drilldown -------- - -networkEarningsRouter.get('/network/partnerships/:id/commissions', requireAuth, async (req, res) => { - const p = req.principal!; - const partnership = await db(TABLES.Partnership).where({ id: req.params.id }).first(); - if (!partnership) return res.status(404).json({ error: 'not_found' }); - - const allowed = - p.role === 'admin' || - (p.role === 'network_creator' && partnership.creatorId === p.networkCreatorId) || - (p.role === 'network_vendor' && partnership.vendorId === p.networkVendorId); - if (!allowed) return res.status(403).json({ error: 'forbidden' }); - - const vendor = await db(TABLES.NetworkVendor).where({ id: partnership.vendorId }).first(); - if (!vendor) return res.status(404).json({ error: 'vendor_missing' }); - - try { - const commissions = await fetchPartnerCommissions(vendor, partnership.vendorPartnerId); - res.json({ commissions }); - } catch (err: unknown) { - res.status(502).json({ - error: 'vendor_unreachable', - detail: err instanceof Error ? err.message : String(err), - }); - } -}); - -function emptyTotals() { - return { - clicks: 0, - attributedEvents: 0, - attributedRevenue: 0, - commission: { accrued: 0, approved: 0, paid: 0, reversed: 0 }, - vendorCount: 0, - healthy: 0, - unreachable: 0, - }; -} - -function computeTotals(rows: PartnershipEarning[]) { - const totals = emptyTotals(); - const vendors = new Set(); - for (const r of rows) { - vendors.add(r.partnership.vendorId); - if (r.status === 'ok' && r.stats) { - totals.clicks += r.stats.clicks; - totals.attributedEvents += r.stats.attributedEvents; - totals.attributedRevenue += r.stats.attributedRevenue; - for (const [status, amount] of Object.entries(r.stats.commissionByStatus ?? {})) { - const bucket = totals.commission as Record; - bucket[status] = (bucket[status] ?? 0) + Number(amount ?? 0); - } - totals.healthy += 1; - } else { - totals.unreachable += 1; - } - } - totals.vendorCount = vendors.size; - return totals; -} diff --git a/apps/api/src/routes/network-offerings.ts b/apps/api/src/routes/network-offerings.ts deleted file mode 100644 index 7e0646d..0000000 --- a/apps/api/src/routes/network-offerings.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Router } from 'express'; -import { ulid } from 'ulid'; -import { TABLES, type NetworkVendorRow, type OfferingRow } from '@openpartner/db'; -import { db } from '../db.js'; -import { requireAuth, requireNetworkVendor } from '../auth.js'; -import { offeringCreateSchema, offeringUpdateSchema } from '../network/validation.js'; - -export const networkOfferingsRouter = Router(); - -// -------- Vendor: manage own offerings -------- - -networkOfferingsRouter.post('/network/offerings', requireAuth, requireNetworkVendor, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_vendor') return res.status(403).json({ error: 'forbidden' }); - - const body = offeringCreateSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const vendor = await db(TABLES.NetworkVendor).where({ id: p.networkVendorId }).first(); - if (!vendor || vendor.status !== 'active') { - return res.status(403).json({ error: 'vendor_not_active' }); - } - - const id = ulid(); - await db(TABLES.Offering).insert({ - id, - vendorId: vendor.id, - title: body.data.title, - productUrl: body.data.productUrl, - description: body.data.description ?? null, - heroImageUrl: body.data.heroImageUrl ?? null, - vendorCampaignId: body.data.vendorCampaignId, - terms: body.data.terms as never, // jsonb - published: body.data.published ?? false, - }); - - const offering = await db(TABLES.Offering).where({ id }).first(); - res.status(201).json({ offering }); -}); - -networkOfferingsRouter.patch('/network/offerings/:id', requireAuth, requireNetworkVendor, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_vendor') return res.status(403).json({ error: 'forbidden' }); - - const body = offeringUpdateSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const existing = await db(TABLES.Offering).where({ id: req.params.id }).first(); - if (!existing) return res.status(404).json({ error: 'offering_not_found' }); - if (existing.vendorId !== p.networkVendorId) return res.status(403).json({ error: 'not_yours' }); - - const patch: Partial = { updatedAt: new Date() }; - if (body.data.title !== undefined) patch.title = body.data.title; - if (body.data.productUrl !== undefined) patch.productUrl = body.data.productUrl; - if (body.data.description !== undefined) patch.description = body.data.description ?? null; - if (body.data.heroImageUrl !== undefined) patch.heroImageUrl = body.data.heroImageUrl ?? null; - if (body.data.vendorCampaignId !== undefined) patch.vendorCampaignId = body.data.vendorCampaignId; - if (body.data.terms !== undefined) patch.terms = body.data.terms as never; - if (body.data.published !== undefined) patch.published = body.data.published; - - await db(TABLES.Offering).where({ id: existing.id }).update(patch); - const fresh = await db(TABLES.Offering).where({ id: existing.id }).first(); - res.json({ offering: fresh }); -}); - -networkOfferingsRouter.get('/network/offerings/mine', requireAuth, requireNetworkVendor, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_vendor') return res.status(403).json({ error: 'forbidden' }); - const offerings = await db(TABLES.Offering) - .where({ vendorId: p.networkVendorId }) - .orderBy('createdAt', 'desc'); - res.json({ offerings }); -}); - -// -------- Public: browse the directory -------- - -networkOfferingsRouter.get('/network/directory/offerings', async (_req, res) => { - const rows = (await db(TABLES.Offering) - .join(TABLES.NetworkVendor, `${TABLES.NetworkVendor}.id`, `${TABLES.Offering}.vendorId`) - .where(`${TABLES.Offering}.published`, true) - .andWhere(`${TABLES.NetworkVendor}.status`, 'active') - .orderBy(`${TABLES.Offering}.createdAt`, 'desc') - .select( - `${TABLES.Offering}.id as id`, - `${TABLES.Offering}.title as title`, - `${TABLES.Offering}.description as description`, - `${TABLES.Offering}.heroImageUrl as heroImageUrl`, - `${TABLES.Offering}.productUrl as productUrl`, - `${TABLES.Offering}.terms as terms`, - `${TABLES.Offering}.createdAt as createdAt`, - `${TABLES.NetworkVendor}.id as vendorId`, - `${TABLES.NetworkVendor}.name as vendorName`, - `${TABLES.NetworkVendor}.slug as vendorSlug`, - `${TABLES.NetworkVendor}.logoUrl as vendorLogoUrl`, - `${TABLES.NetworkVendor}.routerUrl as vendorRouterUrl`, - `${TABLES.NetworkVendor}.instanceUrl as vendorInstanceUrl`, - )) as Array>; - - res.json({ offerings: rows }); -}); - -networkOfferingsRouter.get('/network/directory/offerings/:id', async (req, res) => { - const row = (await db(TABLES.Offering) - .join(TABLES.NetworkVendor, `${TABLES.NetworkVendor}.id`, `${TABLES.Offering}.vendorId`) - .where(`${TABLES.Offering}.id`, req.params.id) - .andWhere(`${TABLES.Offering}.published`, true) - .andWhere(`${TABLES.NetworkVendor}.status`, 'active') - .first( - `${TABLES.Offering}.id as id`, - `${TABLES.Offering}.title as title`, - `${TABLES.Offering}.description as description`, - `${TABLES.Offering}.heroImageUrl as heroImageUrl`, - `${TABLES.Offering}.productUrl as productUrl`, - `${TABLES.Offering}.terms as terms`, - `${TABLES.Offering}.createdAt as createdAt`, - `${TABLES.NetworkVendor}.id as vendorId`, - `${TABLES.NetworkVendor}.name as vendorName`, - `${TABLES.NetworkVendor}.slug as vendorSlug`, - `${TABLES.NetworkVendor}.logoUrl as vendorLogoUrl`, - `${TABLES.NetworkVendor}.description as vendorDescription`, - `${TABLES.NetworkVendor}.websiteUrl as vendorWebsiteUrl`, - `${TABLES.NetworkVendor}.routerUrl as vendorRouterUrl`, - `${TABLES.NetworkVendor}.instanceUrl as vendorInstanceUrl`, - )) as Record | undefined; - - if (!row) return res.status(404).json({ error: 'not_found' }); - res.json({ offering: row }); -}); diff --git a/apps/api/src/routes/network-requests.ts b/apps/api/src/routes/network-requests.ts deleted file mode 100644 index c104630..0000000 --- a/apps/api/src/routes/network-requests.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { Router } from 'express'; -import { ulid } from 'ulid'; -import { - TABLES, - type NetworkCreatorRow, - type NetworkVendorRow, - type OfferingRow, - type PartnershipRequestRow, - type PartnershipRow, -} from '@openpartner/db'; -import { db } from '../db.js'; -import { requireAuth, requireNetworkCreator, requireNetworkVendor } from '../auth.js'; -import { dispatchEvent } from '../webhook-dispatcher.js'; -import { z } from 'zod'; -import { promoCodeSchema, requestCreateSchema, requestDecideSchema } from '../network/validation.js'; - -const inviteSchema = z.object({ - offeringId: z.string().min(1), - creatorId: z.string().min(1), - message: z.string().max(2000).optional(), - promoCode: promoCodeSchema.optional(), -}); -import { provisionPartnerOnVendor } from '../network/federation.js'; - -export const networkRequestsRouter = Router(); - -// -------- Creator: apply to an offering -------- - -networkRequestsRouter.post('/network/requests', requireAuth, requireNetworkCreator, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_creator') return res.status(403).json({ error: 'forbidden' }); - - const body = requestCreateSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const creator = await db(TABLES.NetworkCreator).where({ id: p.networkCreatorId }).first(); - if (!creator || creator.status !== 'active') return res.status(403).json({ error: 'creator_not_active' }); - - const offering = await db(TABLES.Offering).where({ id: body.data.offeringId, published: true }).first(); - if (!offering) return res.status(404).json({ error: 'offering_not_found' }); - - // Fall back chain: request override → creator default → handle. - const promoCode = body.data.promoCode ?? creator.defaultPromoCode ?? creator.handle; - - const id = ulid(); - try { - await db(TABLES.PartnershipRequest).insert({ - id, - offeringId: offering.id, - vendorId: offering.vendorId, - creatorId: creator.id, - direction: 'creator_to_vendor', - message: body.data.message ?? null, - promoCode, - status: 'pending', - }); - } catch (err: unknown) { - if (typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505') { - return res.status(409).json({ error: 'already_requested' }); - } - throw err; - } - const request = await db(TABLES.PartnershipRequest).where({ id }).first(); - res.status(201).json({ request }); -}); - -// -------- Vendor: invite a creator -------- - -networkRequestsRouter.post('/network/invites', requireAuth, requireNetworkVendor, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_vendor') return res.status(403).json({ error: 'forbidden' }); - - const body = inviteSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const offering = await db(TABLES.Offering).where({ id: body.data.offeringId }).first(); - if (!offering) return res.status(404).json({ error: 'offering_not_found' }); - if (offering.vendorId !== p.networkVendorId) return res.status(403).json({ error: 'not_yours' }); - - const creator = await db(TABLES.NetworkCreator).where({ id: body.data.creatorId }).first(); - if (!creator) return res.status(404).json({ error: 'creator_not_found' }); - - const promoCode = body.data.promoCode ?? creator.defaultPromoCode ?? creator.handle; - - const id = ulid(); - try { - await db(TABLES.PartnershipRequest).insert({ - id, - offeringId: offering.id, - vendorId: offering.vendorId, - creatorId: creator.id, - direction: 'vendor_to_creator', - message: body.data.message ?? null, - promoCode, - status: 'pending', - }); - } catch (err: unknown) { - if (typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505') { - return res.status(409).json({ error: 'already_invited' }); - } - throw err; - } - const request = await db(TABLES.PartnershipRequest).where({ id }).first(); - res.status(201).json({ request }); -}); - -// -------- Lists -------- - -networkRequestsRouter.get('/network/requests/mine', requireAuth, async (req, res) => { - const p = req.principal!; - const q = db(TABLES.PartnershipRequest).orderBy('createdAt', 'desc'); - if (p.role === 'network_vendor') q.where({ vendorId: p.networkVendorId }); - else if (p.role === 'network_creator') q.where({ creatorId: p.networkCreatorId }); - else if (p.role !== 'admin') return res.status(403).json({ error: 'forbidden' }); - const requests = await q; - res.json({ requests }); -}); - -// -------- Vendor: approve (federates) or reject -------- - -networkRequestsRouter.post('/network/requests/:id/approve', requireAuth, requireNetworkVendor, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_vendor') return res.status(403).json({ error: 'forbidden' }); - - const body = requestDecideSchema.safeParse(req.body ?? {}); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const reqRow = await db(TABLES.PartnershipRequest).where({ id: req.params.id }).first(); - if (!reqRow) return res.status(404).json({ error: 'request_not_found' }); - if (reqRow.vendorId !== p.networkVendorId) return res.status(403).json({ error: 'not_yours' }); - if (reqRow.status !== 'pending') return res.status(409).json({ error: 'not_pending' }); - - // Claim the request atomically before federating. Two concurrent - // approves would both see status='pending' above; the conditional - // update below only succeeds for the first — the loser returns 409. - // The intermediate 'approving' status is never returned from vendor - // APIs (the loser never sees it), but it keeps the ledger honest. - const claimed = await db(TABLES.PartnershipRequest) - .where({ id: reqRow.id, status: 'pending' }) - .update({ status: 'approving' }); - if (claimed === 0) { - return res.status(409).json({ error: 'not_pending' }); - } - - const [vendor, creator, offering] = await Promise.all([ - db(TABLES.NetworkVendor).where({ id: reqRow.vendorId }).first(), - db(TABLES.NetworkCreator).where({ id: reqRow.creatorId }).first(), - db(TABLES.Offering).where({ id: reqRow.offeringId }).first(), - ]); - if (!vendor || !creator || !offering) { - // Release the claim so a fix-up can retry. - await db(TABLES.PartnershipRequest) - .where({ id: reqRow.id, status: 'approving' }) - .update({ status: 'pending' }); - return res.status(500).json({ error: 'missing_related_rows' }); - } - - let federated; - try { - federated = await provisionPartnerOnVendor({ - vendor, - offering, - creator: { - name: creator.name, - email: creator.email, - handle: creator.handle, - promoCode: reqRow.promoCode, - }, - }); - } catch (err: unknown) { - // Federation failed — release the claim so the vendor can retry. - await db(TABLES.PartnershipRequest) - .where({ id: reqRow.id, status: 'approving' }) - .update({ status: 'pending' }); - const msg = err instanceof Error ? err.message : String(err); - return res.status(502).json({ error: 'federation_failed', detail: msg }); - } - - const partnershipId = ulid(); - await db.transaction(async (trx) => { - await trx(TABLES.PartnershipRequest) - .where({ id: reqRow.id }) - .update({ - status: 'approved', - decidedAt: new Date(), - decisionNote: body.data.decisionNote ?? null, - }); - await trx(TABLES.Partnership).insert({ - id: partnershipId, - requestId: reqRow.id, - offeringId: offering.id, - vendorId: vendor.id, - creatorId: creator.id, - vendorPartnerId: federated.partnerId, - vendorLinkKey: federated.linkKey, - publicShareUrl: federated.publicShareUrl, - status: 'active', - }); - }); - - const partnership = await db(TABLES.Partnership).where({ id: partnershipId }).first(); - if (partnership) { - dispatchEvent('partnership.approved', { - partnershipId: partnership.id, - requestId: reqRow.id, - offeringId: partnership.offeringId, - vendorId: partnership.vendorId, - creatorId: partnership.creatorId, - vendorPartnerId: partnership.vendorPartnerId, - vendorLinkKey: partnership.vendorLinkKey, - publicShareUrl: partnership.publicShareUrl, - }); - } - res.json({ partnership, federated }); -}); - -networkRequestsRouter.post('/network/requests/:id/reject', requireAuth, requireNetworkVendor, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_vendor') return res.status(403).json({ error: 'forbidden' }); - - const body = requestDecideSchema.safeParse(req.body ?? {}); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const reqRow = await db(TABLES.PartnershipRequest).where({ id: req.params.id }).first(); - if (!reqRow) return res.status(404).json({ error: 'request_not_found' }); - if (reqRow.vendorId !== p.networkVendorId) return res.status(403).json({ error: 'not_yours' }); - if (reqRow.status !== 'pending') return res.status(409).json({ error: 'not_pending' }); - - const updated = await db(TABLES.PartnershipRequest) - .where({ id: reqRow.id }) - .update({ status: 'rejected', decidedAt: new Date(), decisionNote: body.data.decisionNote ?? null }) - .returning('*'); - res.json({ request: updated[0] }); -}); - -// -------- Partnerships list -------- - -networkRequestsRouter.get('/network/partnerships/mine', requireAuth, async (req, res) => { - const p = req.principal!; - const q = db(TABLES.Partnership).orderBy('createdAt', 'desc'); - if (p.role === 'network_vendor') q.where({ vendorId: p.networkVendorId }); - else if (p.role === 'network_creator') q.where({ creatorId: p.networkCreatorId }); - else if (p.role !== 'admin') return res.status(403).json({ error: 'forbidden' }); - const partnerships = await q; - res.json({ partnerships }); -}); diff --git a/apps/api/src/routes/network-vendors.ts b/apps/api/src/routes/network-vendors.ts deleted file mode 100644 index f424b7a..0000000 --- a/apps/api/src/routes/network-vendors.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; -import { ulid } from 'ulid'; -import { TABLES, type NetworkVendorRow } from '@openpartner/db'; -import { db } from '../db.js'; -import { createApiKeyRow, requireAdmin, requireAuth, requireNetworkVendor } from '../auth.js'; -import { encryptKey } from '../network/crypto.js'; -import { safeFetch } from '../network/safe-fetch.js'; -import { vendorCreateSchema } from '../network/validation.js'; -import { NETWORK_FEDERATION_SCOPES } from './api-keys.js'; - -export const networkVendorsRouter = Router(); - -const verifyKeySchema = z.object({ - instanceUrl: z.string().url(), - instanceKey: z.string().min(8), -}); - -/** - * Server-side introspection helper. Before an admin registers a vendor, - * the onboarding UI calls this to check the key the vendor pasted. We - * hit the vendor's own /auth/introspect with that key and report back. - * - * Why this exists: we strongly prefer vendors hand us a SCOPED key with - * only the federation permissions (partners:write, links:write, - * partners:read, commissions:read). If they paste a full admin key we - * want to warn them so they can go mint a scoped one instead. - */ -// Intentionally open (no auth) — the vendor is pre-signup and doesn't -// have an account yet. SSRF surface is closed via safeFetch: URL must -// be http(s), hostname must resolve to a public IP (unless the operator -// opts in with NETWORK_ALLOW_PRIVATE_HOSTS=1). We only proxy GET -// /auth/introspect (narrow path), not arbitrary URLs. -networkVendorsRouter.post('/network/vendors/verify-key', async (req, res) => { - const body = verifyKeySchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const { instanceUrl, instanceKey } = body.data; - const url = `${instanceUrl.replace(/\/$/, '')}/auth/introspect`; - try { - const response = await safeFetch(url, { headers: { authorization: `Bearer ${instanceKey}` } }); - const text = await response.text(); - if (!response.ok) { - return res.status(502).json({ - error: 'instance_rejected_key', - status: response.status, - detail: text.slice(0, 300), - }); - } - const introspect = text ? (JSON.parse(text) as Record) : {}; - - const required = [...NETWORK_FEDERATION_SCOPES] as string[]; - const scopes = Array.isArray(introspect.scopes) ? (introspect.scopes as string[]) : null; - const missing = scopes ? required.filter((s) => !scopes.includes(s)) : []; - const unrestricted = introspect.role === 'admin' && introspect.unrestricted === true; - - res.json({ - ok: true, - instanceUrl, - introspect, - recommended: required, - missing, - unrestricted, - // Green — "good to register": - acceptable: (scopes != null && missing.length === 0) || unrestricted, - }); - } catch (err: unknown) { - res.status(502).json({ - error: 'instance_unreachable', - detail: err instanceof Error ? err.message : String(err), - }); - } -}); - -// -------- Admin: list + create + activate -------- - -networkVendorsRouter.get('/network/vendors', requireAuth, requireAdmin, async (_req, res) => { - const vendors = await db(TABLES.NetworkVendor).orderBy('createdAt', 'desc'); - res.json({ vendors: vendors.map(stripKey) }); -}); - -// Vendor self-registration is admin-gated for MVP — keeps quality high -// before we have Stripe-based paid tiers on the Network. -networkVendorsRouter.post('/network/vendors', requireAuth, requireAdmin, async (req, res) => { - const body = vendorCreateSchema.safeParse(req.body); - if (!body.success) return res.status(400).json({ error: 'invalid_body', detail: body.error.flatten() }); - - const id = ulid(); - const prefix = body.data.instanceKey.slice(0, 8); - const ciphertext = encryptKey(body.data.instanceKey); - - try { - await db(TABLES.NetworkVendor).insert({ - id, - name: body.data.name, - slug: body.data.slug, - // Admin-created vendors are pre-verified, so email isn't load-bearing; - // the magic-link signin path still works once the admin sets one. - email: (body.data.email ?? `admin+${body.data.slug}@${new URL(body.data.instanceUrl).hostname}`).toLowerCase(), - websiteUrl: body.data.websiteUrl ?? null, - logoUrl: body.data.logoUrl ?? null, - description: body.data.description ?? null, - instanceUrl: body.data.instanceUrl.replace(/\/$/, ''), - instanceKeyCiphertext: ciphertext, - instanceKeyPrefix: prefix, - routerUrl: body.data.routerUrl ? body.data.routerUrl.replace(/\/$/, '') : null, - status: 'pending', - }); - } catch (err: unknown) { - if (typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505') { - return res.status(409).json({ error: 'slug_taken' }); - } - throw err; - } - - // Issue a vendor-scoped API key so the merchant can sign in to the - // Network-side UI without needing admin rights. - const key = await createApiKeyRow({ networkVendorId: id, label: 'vendor portal' }); - - const vendor = await db(TABLES.NetworkVendor).where({ id }).first(); - res.status(201).json({ - vendor: stripKey(vendor!), - apiKey: key.plaintext, // shown once - }); -}); - -networkVendorsRouter.post('/network/vendors/:id/activate', requireAuth, requireAdmin, async (req, res) => { - const updated = await db(TABLES.NetworkVendor) - .where({ id: req.params.id }) - .update({ status: 'active', activatedAt: new Date() }) - .returning('*'); - if (updated.length === 0) return res.status(404).json({ error: 'vendor_not_found' }); - res.json({ vendor: stripKey(updated[0]!) }); -}); - -networkVendorsRouter.post('/network/vendors/:id/suspend', requireAuth, requireAdmin, async (req, res) => { - const updated = await db(TABLES.NetworkVendor) - .where({ id: req.params.id }) - .update({ status: 'suspended' }) - .returning('*'); - if (updated.length === 0) return res.status(404).json({ error: 'vendor_not_found' }); - res.json({ vendor: stripKey(updated[0]!) }); -}); - -// -------- Vendor: view self -------- - -networkVendorsRouter.get('/network/vendors/me', requireAuth, requireNetworkVendor, async (req, res) => { - const p = req.principal!; - if (p.role !== 'network_vendor') return res.status(403).json({ error: 'forbidden' }); - const vendor = await db(TABLES.NetworkVendor).where({ id: p.networkVendorId }).first(); - if (!vendor) return res.status(404).json({ error: 'vendor_not_found' }); - res.json({ vendor: stripKey(vendor) }); -}); - -// -------- Public-ish: browse active vendors -------- - -networkVendorsRouter.get('/network/directory/vendors', async (_req, res) => { - const vendors = await db(TABLES.NetworkVendor) - .where({ status: 'active' }) - .orderBy('createdAt', 'desc') - .select('id', 'name', 'slug', 'websiteUrl', 'logoUrl', 'description'); - res.json({ vendors }); -}); - -function stripKey(v: NetworkVendorRow): Omit & { instanceKeyPrefix: string } { - const { instanceKeyCiphertext: _omit, ...rest } = v; - return rest; -} diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index 2a24eb6..b87ad69 100644 --- a/apps/portal/src/App.tsx +++ b/apps/portal/src/App.tsx @@ -11,14 +11,6 @@ import { ShieldCheck, Download, LogOut, - Compass, - Package2, - Inbox, - Handshake, - Store, - Megaphone, - Mail, - UserCog, Webhook, } from 'lucide-react'; import { clearApiKey, api, type Principal } from './api.js'; @@ -32,22 +24,9 @@ import { AdminPartners } from './pages/AdminPartners.js'; import { AdminCampaigns } from './pages/AdminCampaigns.js'; import { AdminReview } from './pages/AdminReview.js'; import { AdminExport } from './pages/AdminExport.js'; -import { DiscoverPage } from './pages/network/Discover.js'; -import { MyRequestsPage } from './pages/network/MyRequests.js'; -import { MyPartnershipsPage } from './pages/network/MyPartnerships.js'; -import { VendorOfferingsPage } from './pages/network/VendorOfferings.js'; -import { VendorRequestsPage } from './pages/network/VendorRequests.js'; -import { AdminNetworkVendors } from './pages/network/AdminNetworkVendors.js'; -import { AdminNetworkCreators } from './pages/network/AdminNetworkCreators.js'; import { LoginPage } from './pages/auth/Login.js'; -import { SignupPage } from './pages/auth/Signup.js'; -import { VendorSignupPage } from './pages/auth/VendorSignup.js'; -import { MagicLandingPage } from './pages/auth/MagicLanding.js'; -import { DevMailboxPage } from './pages/admin/DevMailbox.js'; import { WebhooksPage } from './pages/admin/Webhooks.js'; import { FraudReviewPage } from './pages/FraudReview.js'; -import { OfferingDetailPage } from './pages/network/OfferingDetail.js'; -import { CreatorProfilePage } from './pages/network/CreatorProfile.js'; interface AuthState { loading: boolean; @@ -59,9 +38,6 @@ export function App() { } /> - } /> - } /> - } /> } /> @@ -73,9 +49,6 @@ function Shell() { const location = useLocation(); useEffect(() => { - // Always attempt /auth/whoami — it'll accept either the API-key - // Bearer token (if present in localStorage) or the op_session cookie - // from a magic-link sign-in. api('/auth/whoami') .then((p) => setAuth({ loading: false, principal: p })) .catch(() => setAuth({ loading: false, principal: null })); @@ -91,7 +64,6 @@ function Shell() { } /> - {/* Vendor-side OpenPartner (core attribution) */} } /> } /> } /> @@ -103,25 +75,11 @@ function Shell() { } /> } /> } /> - } /> } /> } /> - } /> - } /> )} - {/* OpenPartner Network — creator-side */} - } /> - } /> - } /> - } /> - } /> - - {/* OpenPartner Network — vendor-side */} - } /> - } /> - } /> @@ -142,7 +100,6 @@ function Sidebar({ principal }: { principal: Principal }) { padding: '20px 14px', }} > - {/* Brand */}
OpenPartner
@@ -151,66 +108,29 @@ function Sidebar({ principal }: { principal: Principal }) {
- {/* Core attribution nav — shown to admin + partner */} - {(principal.role === 'admin' || principal.role === 'partner') && ( - - }>Dashboard - }>Links - }>Commissions - }>Payouts - {principal.role === 'partner' && }>Stripe Connect} - - )} + + }>Dashboard + }>Links + }>Commissions + }>Payouts + {principal.role === 'partner' && }>Stripe Connect} + {principal.role === 'admin' && ( - <> - - }>Partners - }>Campaigns - }>Review queue - }>Export / import - }>Fraud review - }>Webhooks - }>Dev mailbox - - - }>Vendors - }>Creators - }>Discover - - - )} - - {principal.role === 'network_vendor' && ( - - }>Offerings - }>Incoming requests - }>Partnerships - }>Discover creators - - )} - - {principal.role === 'network_creator' && ( - - }>Discover - }>My requests - }>Partnerships - }>Profile + + }>Partners + }>Campaigns + }>Review queue + }>Export / import + }>Fraud review + }>Webhooks )}
- ))} -
- )} - - {view === 'html' && message.html ? ( -