create-ailk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +50 -53
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +2 -2
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +33 -5
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +459 -135
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +229 -4
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +14 -13
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +0 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/database/CHANGELOG.md +33 -0
- package/templates/database/inbox/schema.prisma +67 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1296
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/listing-promotion.ts +0 -342
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,566 +0,0 @@
|
|
|
1
|
-
// oss: false
|
|
2
|
-
/**
|
|
3
|
-
* @file tenant-isolation.test.ts
|
|
4
|
-
*
|
|
5
|
-
* THE ACCEPTANCE GATE for the tenant-scoped project-listings routes (#4998
|
|
6
|
-
* AC2, AC3). Registration mirrors server.ts: REAL `authPreHandlerPlugin ->
|
|
7
|
-
* tenantPreHandlerPlugin -> projectListingTenantRoutes`, mocked
|
|
8
|
-
* `lib/prisma.js`. Fixture sessions for: a site-A owner (sees only site A),
|
|
9
|
-
* a site-B admin (sees only site B; 404 on site A's listing id), a site-B
|
|
10
|
-
* member (403 on every route, before any read), a bearer token (403), no
|
|
11
|
-
* cookie (401). Every success body parses under `projectListingSchema`;
|
|
12
|
-
* `waitlistCount` joins on the listing's own `experimentKey` -> `WaitlistScore.count`
|
|
13
|
-
* — one hop since #5036 D6d, where it used to translate an `experimentId`
|
|
14
|
-
* through `WaitlistExperiment` first. The PATCH transition table is exercised
|
|
15
|
-
* exhaustively — every from->to pair (including #4999's one behavior change:
|
|
16
|
-
* a decision target on an already-decided listing is now a `200` idempotent
|
|
17
|
-
* no-op via the shared `decideListing`, not a `400`).
|
|
18
|
-
*/
|
|
19
|
-
import Fastify from "fastify";
|
|
20
|
-
import type { FastifyInstance } from "fastify";
|
|
21
|
-
import { projectListingSchema } from "@working-theory/validation";
|
|
22
|
-
|
|
23
|
-
// `services/project-listings.ts` imports `routeLead` from `lead-routing.js`
|
|
24
|
-
// (used by the draft-submit path, not by these routes) at module scope, which
|
|
25
|
-
// transitively imports `@working-theory/email` and `@working-theory/license`
|
|
26
|
-
// — mocked here purely so that import chain resolves, mirroring
|
|
27
|
-
// `routes/leads/__tests__/index.test.ts`'s discipline.
|
|
28
|
-
jest.mock("@working-theory/email", () => ({
|
|
29
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
30
|
-
}));
|
|
31
|
-
jest.mock("@working-theory/license", () => ({
|
|
32
|
-
checkLicense: jest.fn().mockResolvedValue({
|
|
33
|
-
tier: "free",
|
|
34
|
-
plan: null,
|
|
35
|
-
features: [],
|
|
36
|
-
expiresAt: null,
|
|
37
|
-
revoked: false,
|
|
38
|
-
}),
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
42
|
-
jest.mock("../../../lib/prisma.js", () => ({
|
|
43
|
-
prisma: {
|
|
44
|
-
session: { findUnique: jest.fn() },
|
|
45
|
-
apiToken: {
|
|
46
|
-
findUnique: jest.fn(),
|
|
47
|
-
update: jest.fn().mockResolvedValue(undefined),
|
|
48
|
-
},
|
|
49
|
-
workspace: { findUnique: jest.fn(), count: jest.fn() },
|
|
50
|
-
membership: { findUnique: jest.fn() },
|
|
51
|
-
projectListing: { findFirst: jest.fn(), findMany: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
|
52
|
-
waitlistExperiment: { findMany: jest.fn() },
|
|
53
|
-
waitlistScore: { count: jest.fn() },
|
|
54
|
-
},
|
|
55
|
-
}));
|
|
56
|
-
|
|
57
|
-
import { prisma } from "../../../lib/prisma.js";
|
|
58
|
-
import { authPreHandlerPlugin } from "../../../middleware/auth.js";
|
|
59
|
-
import { tenantPreHandlerPlugin } from "../../../middleware/tenant.js";
|
|
60
|
-
import { projectListingTenantRoutes } from "../index.js";
|
|
61
|
-
|
|
62
|
-
const FRONTEND = "https://app.example.com";
|
|
63
|
-
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
|
|
64
|
-
|
|
65
|
-
const ORG_A = "org_00000000-0000-7000-8000-000000000001";
|
|
66
|
-
const WS_A = "wksp_00000000-0000-7000-8000-000000000001";
|
|
67
|
-
const ORG_B = "org_01991234-5678-7000-8000-0000000000bb";
|
|
68
|
-
const WS_B = "wksp_00000000-0000-7000-8000-0000000000bb";
|
|
69
|
-
|
|
70
|
-
const WORKSPACES = [
|
|
71
|
-
{ id: WS_A, organizationId: ORG_A },
|
|
72
|
-
{ id: WS_B, organizationId: ORG_B },
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
const MEMBERSHIPS = [
|
|
76
|
-
{ organizationId: ORG_A, userId: "alice", role: "owner" },
|
|
77
|
-
{ organizationId: ORG_B, userId: "erin", role: "admin" },
|
|
78
|
-
{ organizationId: ORG_B, userId: "carol", role: "member" },
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
const SESSION_ROWS: Record<string, Record<string, unknown>> = {
|
|
82
|
-
cookie_alice: {
|
|
83
|
-
id: "sess_alice",
|
|
84
|
-
userId: "alice",
|
|
85
|
-
expiresAt: FUTURE,
|
|
86
|
-
activeOrganizationId: ORG_A,
|
|
87
|
-
activeWorkspaceId: WS_A,
|
|
88
|
-
},
|
|
89
|
-
cookie_erin: {
|
|
90
|
-
id: "sess_erin",
|
|
91
|
-
userId: "erin",
|
|
92
|
-
expiresAt: FUTURE,
|
|
93
|
-
activeOrganizationId: ORG_B,
|
|
94
|
-
activeWorkspaceId: WS_B,
|
|
95
|
-
},
|
|
96
|
-
cookie_carol: {
|
|
97
|
-
id: "sess_carol",
|
|
98
|
-
userId: "carol",
|
|
99
|
-
expiresAt: FUTURE,
|
|
100
|
-
activeOrganizationId: ORG_B,
|
|
101
|
-
activeWorkspaceId: WS_B,
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const opt = (value: string, label: string) => ({ value, label });
|
|
106
|
-
const QUESTIONS = {
|
|
107
|
-
q2: { prompt: "x", options: [opt("scale", "Scale"), opt("just-curious", "Just curious")] },
|
|
108
|
-
q3: { prompt: "x", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
109
|
-
q4: { prompt: "x", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
110
|
-
q5: { prompt: "x", options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")] },
|
|
111
|
-
q6: {
|
|
112
|
-
prompt: "x",
|
|
113
|
-
options: [opt("low", "Low"), opt("medium", "Medium"), opt("high", "High"), opt("critical", "Critical")],
|
|
114
|
-
},
|
|
115
|
-
q7: { prompt: "x" },
|
|
116
|
-
extras: [],
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const LISTING_A = "prjl_00000000-0000-7000-8000-0000000000a1";
|
|
120
|
-
const LISTING_B = "prjl_00000000-0000-7000-8000-0000000000b1";
|
|
121
|
-
/** The experiment KEY the listing stores since #5036 D6d — not an id. */
|
|
122
|
-
const EXP_KEY_A = "exp-a";
|
|
123
|
-
|
|
124
|
-
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
|
|
125
|
-
return {
|
|
126
|
-
id: LISTING_A,
|
|
127
|
-
siteId: WS_A,
|
|
128
|
-
leadId: "lead_00000000-0000-7000-8000-000000000001",
|
|
129
|
-
slug: "my-project",
|
|
130
|
-
name: "My Project",
|
|
131
|
-
oneLiner: "A one-liner",
|
|
132
|
-
problem: "The problem",
|
|
133
|
-
url: null,
|
|
134
|
-
// #5036 D2/D5 — `audience` is gone, `stage` is the `product` ladder, and
|
|
135
|
-
// these five are what the page and tile render from.
|
|
136
|
-
product: "working-prototype",
|
|
137
|
-
commitment: "part-time",
|
|
138
|
-
teamComposition: "just-me",
|
|
139
|
-
faqs: null,
|
|
140
|
-
anythingElse: null,
|
|
141
|
-
address: "1 Example Street, Exampleton, EX1 2AM",
|
|
142
|
-
questions: QUESTIONS,
|
|
143
|
-
founderName: "Ada Founder",
|
|
144
|
-
founderEmail: "ada@example.com",
|
|
145
|
-
founderCompany: null,
|
|
146
|
-
headline: null,
|
|
147
|
-
logoMarkUrl: null,
|
|
148
|
-
logoWordmarkUrl: null,
|
|
149
|
-
featured: false,
|
|
150
|
-
status: "requested",
|
|
151
|
-
// Stamped at `start`, never null (#5036 D1).
|
|
152
|
-
consentAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
153
|
-
decidedAt: null,
|
|
154
|
-
decidedBy: null,
|
|
155
|
-
issueUrl: null,
|
|
156
|
-
// The experiment's KEY, not its id (#5036 D6d).
|
|
157
|
-
experimentKey: null,
|
|
158
|
-
windowDays: 21,
|
|
159
|
-
windowEndsAt: null,
|
|
160
|
-
slackChannel: null,
|
|
161
|
-
slackTs: null,
|
|
162
|
-
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
163
|
-
updatedAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
164
|
-
...overrides,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const ROWS = [
|
|
169
|
-
makeRow(),
|
|
170
|
-
makeRow({ id: LISTING_B, siteId: WS_B, slug: "other-project", founderEmail: "bob@example.com" }),
|
|
171
|
-
];
|
|
172
|
-
|
|
173
|
-
function installFixtureMocks(rows = ROWS): void {
|
|
174
|
-
(prisma.session.findUnique as jest.Mock).mockImplementation(
|
|
175
|
-
({ where }: { where: { token?: string } }) =>
|
|
176
|
-
Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
|
|
177
|
-
);
|
|
178
|
-
(prisma.workspace.findUnique as jest.Mock).mockImplementation(
|
|
179
|
-
({ where }: { where: { id?: string } }) =>
|
|
180
|
-
Promise.resolve(WORKSPACES.find((w) => w.id === where.id) ?? null),
|
|
181
|
-
);
|
|
182
|
-
(prisma.workspace.count as jest.Mock).mockResolvedValue(WORKSPACES.length);
|
|
183
|
-
(prisma.membership.findUnique as jest.Mock).mockImplementation(
|
|
184
|
-
({ where }: { where: { organizationId_userId?: { organizationId: string; userId: string } } }) => {
|
|
185
|
-
const key = where.organizationId_userId;
|
|
186
|
-
if (!key) return Promise.resolve(null);
|
|
187
|
-
return Promise.resolve(
|
|
188
|
-
MEMBERSHIPS.find((m) => m.organizationId === key.organizationId && m.userId === key.userId) ?? null,
|
|
189
|
-
);
|
|
190
|
-
},
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
194
|
-
({ where }: { where: { siteId?: string; status?: string; slug?: { startsWith: string } } }) =>
|
|
195
|
-
Promise.resolve(
|
|
196
|
-
rows.filter(
|
|
197
|
-
(r) =>
|
|
198
|
-
r.siteId === where.siteId &&
|
|
199
|
-
(where.status === undefined || r.status === where.status) &&
|
|
200
|
-
(where.slug === undefined || r.slug.startsWith(where.slug.startsWith)),
|
|
201
|
-
),
|
|
202
|
-
),
|
|
203
|
-
);
|
|
204
|
-
(prisma.projectListing.findFirst as jest.Mock).mockImplementation(
|
|
205
|
-
({ where }: { where: { id?: string; siteId?: string } }) =>
|
|
206
|
-
Promise.resolve(rows.find((r) => r.id === where.id && r.siteId === where.siteId) ?? null),
|
|
207
|
-
);
|
|
208
|
-
(prisma.projectListing.update as jest.Mock).mockImplementation(
|
|
209
|
-
({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
210
|
-
const row = rows.find((r) => r.id === where.id);
|
|
211
|
-
if (!row) throw new Error("not found");
|
|
212
|
-
const updated = { ...row, ...data };
|
|
213
|
-
Object.assign(row, updated);
|
|
214
|
-
return Promise.resolve(updated);
|
|
215
|
-
},
|
|
216
|
-
);
|
|
217
|
-
// decideListing's guarded compare-and-swap write (#4999 security review) —
|
|
218
|
-
// only mutates a row when every `where` key matches (a `status: "requested"`
|
|
219
|
-
// mismatch, e.g. already decided, returns `{ count: 0 }` untouched).
|
|
220
|
-
(prisma.projectListing.updateMany as jest.Mock).mockImplementation(
|
|
221
|
-
({
|
|
222
|
-
where,
|
|
223
|
-
data,
|
|
224
|
-
}: {
|
|
225
|
-
where: { id?: string; siteId?: string; status?: string };
|
|
226
|
-
data: Record<string, unknown>;
|
|
227
|
-
}) => {
|
|
228
|
-
const row = rows.find(
|
|
229
|
-
(r) =>
|
|
230
|
-
r.id === where.id &&
|
|
231
|
-
(where.siteId === undefined || r.siteId === where.siteId) &&
|
|
232
|
-
(where.status === undefined || r.status === where.status),
|
|
233
|
-
);
|
|
234
|
-
if (!row) return Promise.resolve({ count: 0 });
|
|
235
|
-
Object.assign(row, data);
|
|
236
|
-
return Promise.resolve({ count: 1 });
|
|
237
|
-
},
|
|
238
|
-
);
|
|
239
|
-
(prisma.waitlistExperiment.findMany as jest.Mock).mockResolvedValue([]);
|
|
240
|
-
(prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function buildApp(): Promise<FastifyInstance> {
|
|
244
|
-
const app = Fastify();
|
|
245
|
-
app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
|
|
246
|
-
app.register(async (scope) => {
|
|
247
|
-
await scope.register(tenantPreHandlerPlugin);
|
|
248
|
-
await scope.register(projectListingTenantRoutes);
|
|
249
|
-
});
|
|
250
|
-
await app.ready();
|
|
251
|
-
return app;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function cookieHeaders(token: string): Record<string, string> {
|
|
255
|
-
// `origin` satisfies the same-origin CSRF check the auth preHandler runs
|
|
256
|
-
// on non-GET cookie requests (middleware/auth.ts) — harmless on GET.
|
|
257
|
-
return { cookie: `better-auth.session_token=${token}`, origin: FRONTEND };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
describe("project-listings — tenant isolation", () => {
|
|
261
|
-
let app: FastifyInstance;
|
|
262
|
-
|
|
263
|
-
beforeEach(async () => {
|
|
264
|
-
jest.clearAllMocks();
|
|
265
|
-
installFixtureMocks();
|
|
266
|
-
app = await buildApp();
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
afterEach(async () => {
|
|
270
|
-
await app.close();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it("a site-A owner sees only site A's listing", async () => {
|
|
274
|
-
const res = await app.inject({
|
|
275
|
-
method: "GET",
|
|
276
|
-
url: "/v1/project-listings",
|
|
277
|
-
headers: cookieHeaders("cookie_alice"),
|
|
278
|
-
});
|
|
279
|
-
expect(res.statusCode).toBe(200);
|
|
280
|
-
const body = res.json();
|
|
281
|
-
expect(body.data).toHaveLength(1);
|
|
282
|
-
expect(body.data[0].id).toBe(LISTING_A);
|
|
283
|
-
expect(projectListingSchema.safeParse(body.data[0]).success).toBe(true);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("a site-B admin sees only site B's listing", async () => {
|
|
287
|
-
const res = await app.inject({
|
|
288
|
-
method: "GET",
|
|
289
|
-
url: "/v1/project-listings",
|
|
290
|
-
headers: cookieHeaders("cookie_erin"),
|
|
291
|
-
});
|
|
292
|
-
expect(res.statusCode).toBe(200);
|
|
293
|
-
expect(res.json().data.map((r: { id: string }) => r.id)).toEqual([LISTING_B]);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("404s on the other site's listing id, indistinguishable from a genuinely-missing one", async () => {
|
|
297
|
-
const foreign = await app.inject({
|
|
298
|
-
method: "GET",
|
|
299
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
300
|
-
headers: cookieHeaders("cookie_erin"),
|
|
301
|
-
});
|
|
302
|
-
expect(foreign.statusCode).toBe(404);
|
|
303
|
-
|
|
304
|
-
const missing = await app.inject({
|
|
305
|
-
method: "GET",
|
|
306
|
-
url: "/v1/project-listings/prjl_00000000-0000-7000-8000-00000000dead",
|
|
307
|
-
headers: cookieHeaders("cookie_alice"),
|
|
308
|
-
});
|
|
309
|
-
expect(missing.statusCode).toBe(404);
|
|
310
|
-
expect(missing.body).toBe(foreign.body);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it("400s on a malformed :id", async () => {
|
|
314
|
-
const res = await app.inject({
|
|
315
|
-
method: "GET",
|
|
316
|
-
url: "/v1/project-listings/not-a-valid-id",
|
|
317
|
-
headers: cookieHeaders("cookie_alice"),
|
|
318
|
-
});
|
|
319
|
-
expect(res.statusCode).toBe(400);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("a site-B member gets 403 on every route, before any read", async () => {
|
|
323
|
-
const list = await app.inject({
|
|
324
|
-
method: "GET",
|
|
325
|
-
url: "/v1/project-listings",
|
|
326
|
-
headers: cookieHeaders("cookie_carol"),
|
|
327
|
-
});
|
|
328
|
-
expect(list.statusCode).toBe(403);
|
|
329
|
-
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
330
|
-
|
|
331
|
-
const patchRes = await app.inject({
|
|
332
|
-
method: "PATCH",
|
|
333
|
-
url: `/v1/project-listings/${LISTING_B}`,
|
|
334
|
-
headers: cookieHeaders("cookie_carol"),
|
|
335
|
-
payload: { status: "active" },
|
|
336
|
-
});
|
|
337
|
-
expect(patchRes.statusCode).toBe(403);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it("a bearer token gets 403 (no tenant binding)", async () => {
|
|
341
|
-
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue({
|
|
342
|
-
id: "tok1",
|
|
343
|
-
userId: "erin",
|
|
344
|
-
expiresAt: null,
|
|
345
|
-
revokedAt: null,
|
|
346
|
-
});
|
|
347
|
-
const res = await app.inject({
|
|
348
|
-
method: "GET",
|
|
349
|
-
url: "/v1/project-listings",
|
|
350
|
-
headers: { authorization: "Bearer tok_raw" },
|
|
351
|
-
});
|
|
352
|
-
expect(res.statusCode).toBe(403);
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it("no cookie gets 401", async () => {
|
|
356
|
-
const res = await app.inject({ method: "GET", url: "/v1/project-listings" });
|
|
357
|
-
expect(res.statusCode).toBe(401);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it("GET :id includes waitlistCount joined on the listing's own experimentKey, in one hop", async () => {
|
|
361
|
-
installFixtureMocks([makeRow({ experimentKey: EXP_KEY_A })]);
|
|
362
|
-
(prisma.waitlistScore.count as jest.Mock).mockResolvedValue(7);
|
|
363
|
-
|
|
364
|
-
const res = await app.inject({
|
|
365
|
-
method: "GET",
|
|
366
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
367
|
-
headers: cookieHeaders("cookie_alice"),
|
|
368
|
-
});
|
|
369
|
-
expect(res.statusCode).toBe(200);
|
|
370
|
-
expect(res.json().data.waitlistCount).toBe(7);
|
|
371
|
-
expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
|
|
372
|
-
where: { siteId: WS_A, experimentKey: EXP_KEY_A },
|
|
373
|
-
});
|
|
374
|
-
// #5036 D6d removed the id-to-key translation read entirely.
|
|
375
|
-
expect(prisma.waitlistExperiment.findMany).not.toHaveBeenCalled();
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it("waitlistCount is 0 when experimentKey is unset", async () => {
|
|
379
|
-
installFixtureMocks([makeRow()]);
|
|
380
|
-
const res = await app.inject({
|
|
381
|
-
method: "GET",
|
|
382
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
383
|
-
headers: cookieHeaders("cookie_alice"),
|
|
384
|
-
});
|
|
385
|
-
expect(res.json().data.waitlistCount).toBe(0);
|
|
386
|
-
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// ── AC3 — the transition surface, exhaustively ───────────────────────────
|
|
390
|
-
//
|
|
391
|
-
// #5033 collapsed the lifecycle to five states with `closed` COMPUTED, so
|
|
392
|
-
// the transition table this suite used to walk has one row left:
|
|
393
|
-
// `requested -> active | rejected`. `approved -> live` and `live -> closed`
|
|
394
|
-
// no longer exist as concepts. The idempotent "second click" branch (#4999)
|
|
395
|
-
// is unchanged: a decision target on an already-decided row is `200` with
|
|
396
|
-
// the row untouched, not a `400`.
|
|
397
|
-
//
|
|
398
|
-
// The narrowing is deliberate and is what makes "nothing can write
|
|
399
|
-
// `closed`" structural: the patch body's enum does not contain it, so the
|
|
400
|
-
// request never reaches a service that could.
|
|
401
|
-
|
|
402
|
-
const STORED = ["draft", "requested", "rejected", "active"] as const;
|
|
403
|
-
const DECISION_TARGETS = ["active", "rejected"] as const;
|
|
404
|
-
const DECIDED = new Set<string>(["active", "rejected"]);
|
|
405
|
-
|
|
406
|
-
for (const from of STORED) {
|
|
407
|
-
for (const to of DECISION_TARGETS) {
|
|
408
|
-
if (from === "requested") {
|
|
409
|
-
it(`${from} -> ${to} is 200 (the one allowed transition)`, async () => {
|
|
410
|
-
installFixtureMocks([makeRow({ status: from })]);
|
|
411
|
-
const res = await app.inject({
|
|
412
|
-
method: "PATCH",
|
|
413
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
414
|
-
headers: cookieHeaders("cookie_alice"),
|
|
415
|
-
payload: { status: to },
|
|
416
|
-
});
|
|
417
|
-
expect(res.statusCode).toBe(200);
|
|
418
|
-
expect(res.json().data.status).toBe(to);
|
|
419
|
-
});
|
|
420
|
-
} else if (DECIDED.has(from)) {
|
|
421
|
-
it(`${from} -> ${to} is 200 idempotent (already decided — #4999 second-click no-op), status stays '${from}'`, async () => {
|
|
422
|
-
// An already-`active` row needs the window `decideListing` stamped
|
|
423
|
-
// on it: since #5036 D6b a non-featured active row with no window in
|
|
424
|
-
// force reads `closed`, which would make this an assertion about the
|
|
425
|
-
// fail-closed branch rather than about idempotency.
|
|
426
|
-
installFixtureMocks([
|
|
427
|
-
makeRow({
|
|
428
|
-
status: from,
|
|
429
|
-
...(from === "active" && {
|
|
430
|
-
windowEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
431
|
-
}),
|
|
432
|
-
}),
|
|
433
|
-
]);
|
|
434
|
-
const res = await app.inject({
|
|
435
|
-
method: "PATCH",
|
|
436
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
437
|
-
headers: cookieHeaders("cookie_alice"),
|
|
438
|
-
payload: { status: to },
|
|
439
|
-
});
|
|
440
|
-
expect(res.statusCode).toBe(200);
|
|
441
|
-
expect(res.json().data.status).toBe(from);
|
|
442
|
-
// decideListing's guarded updateMany (`where` includes
|
|
443
|
-
// `status: "requested"`) is attempted but matches 0 rows, so the
|
|
444
|
-
// mock's own guard means it never mutated the row either.
|
|
445
|
-
expect(prisma.projectListing.updateMany).toHaveBeenCalledWith(
|
|
446
|
-
expect.objectContaining({ where: expect.objectContaining({ status: "requested" }) }),
|
|
447
|
-
);
|
|
448
|
-
});
|
|
449
|
-
} else {
|
|
450
|
-
it(`${from} -> ${to} is 400 (rejected)`, async () => {
|
|
451
|
-
installFixtureMocks([makeRow({ status: from })]);
|
|
452
|
-
const res = await app.inject({
|
|
453
|
-
method: "PATCH",
|
|
454
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
455
|
-
headers: cookieHeaders("cookie_alice"),
|
|
456
|
-
payload: { status: to },
|
|
457
|
-
});
|
|
458
|
-
expect(res.statusCode).toBe(400);
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// The invariant, at the route boundary: no request can store `closed`, and
|
|
465
|
-
// no request can reach the retired vocabulary either.
|
|
466
|
-
it.each(["closed", "draft", "requested", "applied", "approved", "live"])(
|
|
467
|
-
"400s a PATCH asking for status '%s', and writes nothing",
|
|
468
|
-
async (status) => {
|
|
469
|
-
installFixtureMocks([makeRow({ status: "requested" })]);
|
|
470
|
-
const res = await app.inject({
|
|
471
|
-
method: "PATCH",
|
|
472
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
473
|
-
headers: cookieHeaders("cookie_alice"),
|
|
474
|
-
payload: { status },
|
|
475
|
-
});
|
|
476
|
-
expect(res.statusCode).toBe(400);
|
|
477
|
-
expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
|
|
478
|
-
},
|
|
479
|
-
);
|
|
480
|
-
|
|
481
|
-
it("stamps decidedAt/decidedBy on requested -> active and requested -> rejected, from request.userId", async () => {
|
|
482
|
-
installFixtureMocks([makeRow({ status: "requested" })]);
|
|
483
|
-
const res = await app.inject({
|
|
484
|
-
method: "PATCH",
|
|
485
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
486
|
-
headers: cookieHeaders("cookie_alice"),
|
|
487
|
-
payload: { status: "active" },
|
|
488
|
-
});
|
|
489
|
-
expect(res.statusCode).toBe(200);
|
|
490
|
-
const body = res.json().data;
|
|
491
|
-
expect(body.decidedBy).toBe("alice");
|
|
492
|
-
expect(body.decidedAt).not.toBeNull();
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it("400s on an unknown body field", async () => {
|
|
496
|
-
installFixtureMocks([makeRow({ status: "requested" })]);
|
|
497
|
-
const res = await app.inject({
|
|
498
|
-
method: "PATCH",
|
|
499
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
500
|
-
headers: cookieHeaders("cookie_alice"),
|
|
501
|
-
payload: { status: "active", decidedBy: "someone-else" },
|
|
502
|
-
});
|
|
503
|
-
expect(res.statusCode).toBe(400);
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it("400s on an empty body", async () => {
|
|
507
|
-
installFixtureMocks([makeRow({ status: "requested" })]);
|
|
508
|
-
const res = await app.inject({
|
|
509
|
-
method: "PATCH",
|
|
510
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
511
|
-
headers: cookieHeaders("cookie_alice"),
|
|
512
|
-
payload: {},
|
|
513
|
-
});
|
|
514
|
-
expect(res.statusCode).toBe(400);
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
// ── #5033 — the featured flag, scoped to the tenant ─────────────────────
|
|
518
|
-
|
|
519
|
-
it("sets the featured flag on its own, scoped to the caller's site", async () => {
|
|
520
|
-
installFixtureMocks([makeRow({ status: "active" })]);
|
|
521
|
-
const res = await app.inject({
|
|
522
|
-
method: "PATCH",
|
|
523
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
524
|
-
headers: cookieHeaders("cookie_alice"),
|
|
525
|
-
payload: { featured: true },
|
|
526
|
-
});
|
|
527
|
-
expect(res.statusCode).toBe(200);
|
|
528
|
-
expect(res.json().data.featured).toBe(true);
|
|
529
|
-
expect(prisma.projectListing.updateMany).toHaveBeenCalledWith(
|
|
530
|
-
expect.objectContaining({ where: expect.objectContaining({ siteId: WS_A }) }),
|
|
531
|
-
);
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
it("404s a featured write against a foreign listing id", async () => {
|
|
535
|
-
const res = await app.inject({
|
|
536
|
-
method: "PATCH",
|
|
537
|
-
url: `/v1/project-listings/${LISTING_B}`,
|
|
538
|
-
headers: cookieHeaders("cookie_alice"),
|
|
539
|
-
payload: { featured: true },
|
|
540
|
-
});
|
|
541
|
-
expect(res.statusCode).toBe(404);
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it("400s a PATCH trying to set the site's own config from the request body", async () => {
|
|
545
|
-
installFixtureMocks([makeRow({ status: "requested" })]);
|
|
546
|
-
for (const key of ["maxWindowDays", "defaultWindowDays", "featuredEnabled", "windowEndsAt"]) {
|
|
547
|
-
const res = await app.inject({
|
|
548
|
-
method: "PATCH",
|
|
549
|
-
url: `/v1/project-listings/${LISTING_A}`,
|
|
550
|
-
headers: cookieHeaders("cookie_alice"),
|
|
551
|
-
payload: { status: "active", [key]: 3000 },
|
|
552
|
-
});
|
|
553
|
-
expect(res.statusCode).toBe(400);
|
|
554
|
-
}
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
it("404s a PATCH on a foreign listing id", async () => {
|
|
558
|
-
const res = await app.inject({
|
|
559
|
-
method: "PATCH",
|
|
560
|
-
url: `/v1/project-listings/${LISTING_B}`,
|
|
561
|
-
headers: cookieHeaders("cookie_alice"),
|
|
562
|
-
payload: { status: "active" },
|
|
563
|
-
});
|
|
564
|
-
expect(res.statusCode).toBe(404);
|
|
565
|
-
});
|
|
566
|
-
});
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
// oss: false
|
|
2
|
-
/**
|
|
3
|
-
* GET /v1/project-listings/:id
|
|
4
|
-
*
|
|
5
|
-
* Tenant-scoped single read (#4998 AC2). `:id` is validated with
|
|
6
|
-
* `zProjectListingId` (ADR-0031 rule 5) before any lookup — a malformed id is
|
|
7
|
-
* `400`, never reaches the service. A foreign or missing id is `404`,
|
|
8
|
-
* indistinguishable (the `waitlist-experiments` no-enumeration posture).
|
|
9
|
-
*/
|
|
10
|
-
import { zProjectListingId } from "@working-theory/ids";
|
|
11
|
-
import { projectListingSchema } from "@working-theory/validation";
|
|
12
|
-
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
13
|
-
import { z } from "zod";
|
|
14
|
-
|
|
15
|
-
import { getProjectListing, PROJECT_LISTING_ROLES } from "../../services/project-listings.js";
|
|
16
|
-
|
|
17
|
-
import { sendValidated } from "./respond.js";
|
|
18
|
-
|
|
19
|
-
const paramsSchema = z.object({ id: zProjectListingId });
|
|
20
|
-
|
|
21
|
-
function unauthorized(reply: FastifyReply) {
|
|
22
|
-
return reply.status(401).send({
|
|
23
|
-
ok: false,
|
|
24
|
-
error: { code: "unauthorized", message: "Authentication required" },
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function forbidden(reply: FastifyReply) {
|
|
29
|
-
return reply.status(403).send({
|
|
30
|
-
ok: false,
|
|
31
|
-
error: { code: "forbidden", message: "Forbidden" },
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function invalidParams(reply: FastifyReply) {
|
|
36
|
-
return reply.status(400).send({
|
|
37
|
-
ok: false,
|
|
38
|
-
error: { code: "validation_failed", message: "Invalid project listing id" },
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function notFound(reply: FastifyReply) {
|
|
43
|
-
return reply.status(404).send({
|
|
44
|
-
ok: false,
|
|
45
|
-
error: { code: "not_found", message: "Project listing not found" },
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function getProjectListingHandler(
|
|
50
|
-
request: FastifyRequest,
|
|
51
|
-
reply: FastifyReply,
|
|
52
|
-
): Promise<void> {
|
|
53
|
-
const tenant = request.tenant;
|
|
54
|
-
if (!tenant) return unauthorized(reply);
|
|
55
|
-
if (!PROJECT_LISTING_ROLES.has(tenant.role)) {
|
|
56
|
-
request.log.warn(
|
|
57
|
-
{ method: request.method, path: request.url, status: 403 },
|
|
58
|
-
"[project-listings] get denied; insufficient role",
|
|
59
|
-
);
|
|
60
|
-
return forbidden(reply);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const parsedParams = paramsSchema.safeParse(request.params);
|
|
64
|
-
if (!parsedParams.success) return invalidParams(reply);
|
|
65
|
-
|
|
66
|
-
const result = await getProjectListing(tenant.workspaceId, parsedParams.data.id);
|
|
67
|
-
if (!result.ok) return notFound(reply);
|
|
68
|
-
|
|
69
|
-
return sendValidated(reply, projectListingSchema, result.value, "get");
|
|
70
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
// oss: false
|
|
2
|
-
/**
|
|
3
|
-
* GET /v1/project-listings
|
|
4
|
-
*
|
|
5
|
-
* Tenant-scoped dashboard list (#4998 AC2), the `waitlist-experiments` list
|
|
6
|
-
* route's own pattern: scope derived server-side from `request.tenant`
|
|
7
|
-
* (never a client-supplied site id), role floor `owner|admin` checked before
|
|
8
|
-
* any read, response re-parsed against its own contract schema before send.
|
|
9
|
-
*/
|
|
10
|
-
import { projectListingListResponseSchema } from "@working-theory/validation";
|
|
11
|
-
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
12
|
-
|
|
13
|
-
import { listProjectListings, PROJECT_LISTING_ROLES } from "../../services/project-listings.js";
|
|
14
|
-
|
|
15
|
-
import { sendValidated } from "./respond.js";
|
|
16
|
-
|
|
17
|
-
function unauthorized(reply: FastifyReply) {
|
|
18
|
-
return reply.status(401).send({
|
|
19
|
-
ok: false,
|
|
20
|
-
error: { code: "unauthorized", message: "Authentication required" },
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function forbidden(reply: FastifyReply) {
|
|
25
|
-
return reply.status(403).send({
|
|
26
|
-
ok: false,
|
|
27
|
-
error: { code: "forbidden", message: "Forbidden" },
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function listProjectListingsHandler(
|
|
32
|
-
request: FastifyRequest,
|
|
33
|
-
reply: FastifyReply,
|
|
34
|
-
): Promise<void> {
|
|
35
|
-
const tenant = request.tenant;
|
|
36
|
-
if (!tenant) return unauthorized(reply);
|
|
37
|
-
if (!PROJECT_LISTING_ROLES.has(tenant.role)) {
|
|
38
|
-
request.log.warn(
|
|
39
|
-
{ method: request.method, path: request.url, status: 403 },
|
|
40
|
-
"[project-listings] list denied; insufficient role",
|
|
41
|
-
);
|
|
42
|
-
return forbidden(reply);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const data = await listProjectListings(tenant.workspaceId);
|
|
46
|
-
return sendValidated(reply, projectListingListResponseSchema, data, "list");
|
|
47
|
-
}
|