create-ailk 0.1.0 → 0.2.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/README.md +124 -3
- package/component-catalog.md +456 -0
- package/dist/cli.js +92 -16
- package/dist/component-catalog.d.ts +11 -0
- package/dist/component-catalog.js +39 -0
- package/dist/copy.d.ts +7 -0
- package/dist/copy.js +24 -0
- package/dist/derive-architecture.d.ts +13 -0
- package/dist/derive-architecture.js +238 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +19 -2
- package/dist/lib/extract-module-bundle.d.ts +16 -0
- package/dist/lib/extract-module-bundle.js +184 -0
- package/dist/lib/fetch-module.d.ts +55 -0
- package/dist/lib/fetch-module.js +125 -0
- package/dist/lib/module-license-gate.d.ts +48 -0
- package/dist/lib/module-license-gate.js +67 -0
- package/dist/lib/module-tier.d.ts +35 -0
- package/dist/lib/module-tier.js +37 -0
- package/dist/module-architecture.d.ts +175 -0
- package/dist/module-architecture.js +663 -0
- package/dist/parse-args.d.ts +47 -1
- package/dist/parse-args.js +143 -2
- package/dist/programmatic.d.ts +160 -3
- package/dist/programmatic.js +196 -7
- package/dist/surfaces.d.ts +211 -0
- package/dist/surfaces.js +532 -0
- package/dist/sync-routes.d.ts +92 -0
- package/dist/sync-routes.js +350 -0
- package/package.json +12 -5
- package/templates/.claude/rules/architecture.md +6 -2
- package/templates/.claude/skills/README.md +9 -9
- package/templates/.claude/skills/add-schema/SKILL.md +14 -7
- package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
- package/templates/.claude/skills/provision-config/SKILL.md +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
- package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
- package/templates/.env.example +126 -2
- package/templates/CLAUDE.md +11 -5
- package/templates/README.md +1 -1
- package/templates/apps/api/.env.example +38 -0
- package/templates/apps/api/CLAUDE.md +78 -1
- package/templates/apps/api/LICENSE +201 -0
- package/templates/apps/api/api/index.ts +6 -141
- package/templates/apps/api/jest.config.cjs +16 -0
- package/templates/apps/api/package.json +22 -6
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
- package/templates/apps/api/src/__tests__/server.test.ts +46 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
- package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
- package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
- package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
- package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
- package/templates/apps/api/src/bin/seed-presets.ts +58 -0
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
- package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
- package/templates/apps/api/src/cli.ts +5 -11
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/config/modules.ts +43 -0
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
- package/templates/apps/api/src/lib/site.ts +16 -0
- package/templates/apps/api/src/lib/stripe.ts +22 -0
- package/templates/apps/api/src/lib/tenant-db.ts +225 -0
- package/templates/apps/api/src/lib/ws-token.ts +91 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
- package/templates/apps/api/src/middleware/auth.ts +43 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
- package/templates/apps/api/src/middleware/tenant.ts +99 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
- package/templates/apps/api/src/openapi/spec.ts +312 -3
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
- package/templates/apps/api/src/routes/aeo/index.ts +107 -0
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
- package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
- package/templates/apps/api/src/routes/billing/index.ts +36 -0
- package/templates/apps/api/src/routes/billing/portal.ts +182 -0
- package/templates/apps/api/src/routes/billing/read.ts +75 -0
- package/templates/apps/api/src/routes/billing/usage.ts +153 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
- package/templates/apps/api/src/routes/content/create.ts +1 -0
- package/templates/apps/api/src/routes/content/delete.ts +1 -0
- package/templates/apps/api/src/routes/content/index.ts +42 -6
- package/templates/apps/api/src/routes/content/update.ts +1 -0
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
- package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
- package/templates/apps/api/src/routes/flows/README.md +147 -0
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
- package/templates/apps/api/src/routes/flows/index.ts +202 -0
- package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
- package/templates/apps/api/src/routes/leads/index.ts +43 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
- package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
- package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
- package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
- package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
- package/templates/apps/api/src/routes/schedule/index.ts +249 -0
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
- package/templates/apps/api/src/routes/slack/actions.ts +177 -0
- package/templates/apps/api/src/routes/slack/index.ts +38 -0
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
- package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
- package/templates/apps/api/src/routes/webhooks/README.md +35 -12
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
- package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
- package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
- package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
- package/templates/apps/api/src/server.ts +307 -7
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
- package/templates/apps/api/src/services/abandon-email.ts +307 -0
- package/templates/apps/api/src/services/aeo-score.ts +288 -0
- package/templates/apps/api/src/services/answer-distributions.ts +138 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
- package/templates/apps/api/src/services/entitlements.ts +102 -0
- package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
- package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
- package/templates/apps/api/src/services/flow-engine.ts +1296 -0
- package/templates/apps/api/src/services/lead-promotion.ts +176 -0
- package/templates/apps/api/src/services/lead-routing.ts +3 -3
- package/templates/apps/api/src/services/listing-config.ts +102 -0
- package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
- package/templates/apps/api/src/services/listing-promotion.ts +342 -0
- package/templates/apps/api/src/services/playbook-compile.ts +398 -0
- package/templates/apps/api/src/services/playbook-render.ts +263 -0
- package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
- package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
- package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
- package/templates/apps/api/src/services/project-listings.ts +1472 -0
- package/templates/apps/api/src/services/recommender-capture.ts +835 -0
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
- package/templates/apps/api/src/services/scheduling/index.ts +63 -0
- package/templates/apps/api/src/services/scheduling/types.ts +88 -0
- package/templates/apps/api/src/services/slack-notify.ts +240 -0
- package/templates/apps/api/src/services/tenant-context.ts +451 -0
- package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
- package/templates/apps/api/src/services/usage-credits.ts +340 -0
- package/templates/apps/api/src/services/usage-metering.ts +699 -0
- package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
- package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
- package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
- package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
- package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
- package/templates/apps/api/src/vercel-handler.ts +165 -0
- package/templates/apps/api/tsconfig.json +0 -12
- package/templates/apps/mcp/CLAUDE.md +19 -9
- package/templates/apps/mcp/LICENSE +201 -0
- package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
- package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
- package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
- package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
- package/templates/apps/mcp/package.json +6 -4
- package/templates/apps/mcp/src/config/modules.ts +37 -0
- package/templates/apps/mcp/src/server.ts +109 -18
- package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
- package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
- package/templates/apps/mcp/src/tools/create_page.ts +1 -0
- package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
- package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
- package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
- package/templates/apps/mcp/src/tools/index.ts +25 -0
- package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
- package/templates/apps/mcp/src/tools/purchase.ts +3 -0
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
- package/templates/apps/mcp/src/tools/update_page.ts +1 -0
- package/templates/apps/mcp/tsconfig.json +1 -9
- package/templates/apps/web/.env.example +44 -0
- package/templates/apps/web/CLAUDE.md +28 -0
- package/templates/apps/web/LICENSE +201 -0
- package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
- package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
- package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
- package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
- package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
- package/templates/apps/web/__tests__/setup.ts +22 -32
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
- package/templates/apps/web/app/[locale]/layout.tsx +127 -16
- package/templates/apps/web/app/[locale]/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
- package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
- package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
- package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
- package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/globals.css +35 -0
- package/templates/apps/web/app/llms.txt/route.ts +2 -1
- package/templates/apps/web/app/og/route.tsx +10 -2
- package/templates/apps/web/auth.ts +8 -0
- package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
- package/templates/apps/web/content/en/blog/README.txt +25 -0
- package/templates/apps/web/content/en/docs/index.mdx +28 -0
- package/templates/apps/web/jest.config.cjs +90 -27
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
- package/templates/apps/web/lib/account-config.ts +13 -0
- package/templates/apps/web/lib/server-api.ts +85 -0
- package/templates/apps/web/lib/site-brand.tsx +121 -0
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/messages/en.json +275 -7
- package/templates/apps/web/middleware.ts +11 -3
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/apps/web/package.json +17 -7
- package/templates/apps/web/postcss.config.mjs +23 -0
- package/templates/apps/web/public/.well-known/security.txt +3 -3
- package/templates/apps/web/src/lib/routes.ts +15 -2
- package/templates/apps/web/tsconfig.json +1 -16
- package/templates/content/_site.mdx +36 -25
- package/templates/content/en/AboutPage/about.mdx +24 -14
- package/templates/content/en/ContactPage/contact.mdx +23 -14
- package/templates/content/en/FAQPage/faq.mdx +10 -392
- package/templates/content/en/HomePage/home.mdx +14 -285
- package/templates/database/CHANGELOG.md +443 -0
- package/templates/database/LICENSE +201 -0
- package/templates/database/README.md +3 -1
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
- package/templates/database/__tests__/flows-presets.test.ts +173 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
- package/templates/database/__tests__/seed.test.ts +88 -0
- package/templates/database/__tests__/user-cascade.test.ts +19 -4
- package/templates/database/auth/schema.prisma +521 -0
- package/templates/database/config/schema.prisma +234 -0
- package/templates/database/content/schema.prisma +263 -0
- package/templates/database/flows/listing-diagnostic.ts +182 -0
- package/templates/database/flows/presets.ts +315 -0
- package/templates/database/inbox/__tests__/schema.test.ts +4 -0
- package/templates/database/inbox/schema.prisma +574 -3
- package/templates/database/jest.config.cjs +8 -0
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/ops/schema.prisma +27 -0
- package/templates/database/package.json +42 -4
- package/templates/database/prisma.config.ts +1 -1
- package/templates/database/schema.prisma +19 -435
- package/templates/database/seed-test.ts +14 -4
- package/templates/database/seed.ts +148 -0
- package/templates/database/tenancy.ts +45 -0
- package/templates/database/tsconfig.flows.json +15 -0
- package/templates/database/tsconfig.json +7 -1
- package/templates/database/tsconfig.tenancy.json +14 -0
- package/templates/package.json +30 -55
- package/templates/pnpm-workspace.yaml +0 -3
- package/templates/project.yaml +1 -1
- package/templates/tsconfig.json +1 -37
- package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
- package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
- package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
- package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
- package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
- package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
- package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
- package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
- package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
- package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
- package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
- package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
- package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
- package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
- package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
- package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
- package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
- package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
- package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
- package/templates/apps/web/__tests__/page.test.tsx +0 -230
- package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
- package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
- package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
- package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
- package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
- package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
- package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
- package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
- package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
- package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
- package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
- package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
- package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
- package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
- package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
- package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
- package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
- package/templates/apps/web/content/en/blog/index.mdx +0 -15
- package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
- package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
- package/templates/content/.gitkeep +0 -0
- package/templates/content/CLAUDE.md +0 -85
- package/templates/content/ar/HomePage/home.mdx +0 -123
- package/templates/content/ar/_site.mdx +0 -35
- package/templates/content/de/_site.mdx +0 -29
- package/templates/content/en/.gitkeep +0 -0
- package/templates/content/en/AboutPage/about.schema.json +0 -82
- package/templates/content/en/BookCall/book-call.mdx +0 -19
- package/templates/content/en/BookCall/book-call.schema.json +0 -121
- package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
- package/templates/content/en/CareersList/careers.mdx +0 -24
- package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
- package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
- package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
- package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
- package/templates/content/en/ClientsList/clients.mdx +0 -38
- package/templates/content/en/ContactPage/contact.schema.json +0 -100
- package/templates/content/en/ContactPoint/primary.mdx +0 -14
- package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
- package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
- package/templates/content/en/CoursesList/courses.mdx +0 -22
- package/templates/content/en/DownloadsList/downloads.mdx +0 -22
- package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
- package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
- package/templates/content/en/EventsList/events.mdx +0 -26
- package/templates/content/en/FAQPage/faq.schema.json +0 -231
- package/templates/content/en/GlossaryList/glossary.mdx +0 -19
- package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
- package/templates/content/en/HomePage/home.schema.json +0 -88
- package/templates/content/en/HomePage/launch-service.mdx +0 -475
- package/templates/content/en/HomePage/launch-service.schema.json +0 -138
- package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
- package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
- package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
- package/templates/content/en/Integration/github-actions.mdx +0 -31
- package/templates/content/en/Integration/openai-api.mdx +0 -26
- package/templates/content/en/Integration/vercel.mdx +0 -32
- package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
- package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
- package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
- package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
- package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
- package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
- package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
- package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
- package/templates/content/en/LandingPage/developers.mdx +0 -19
- package/templates/content/en/LandingPage/developers.schema.json +0 -109
- package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
- package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
- package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
- package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
- package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
- package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
- package/templates/content/en/LandingPage/platform.mdx +0 -369
- package/templates/content/en/LandingPage/platform.schema.json +0 -166
- package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
- package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
- package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
- package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
- package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
- package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
- package/templates/content/en/Organization/site-owner.mdx +0 -16
- package/templates/content/en/Partner/prisma.mdx +0 -31
- package/templates/content/en/Partner/supabase.mdx +0 -30
- package/templates/content/en/Partner/vercel.mdx +0 -28
- package/templates/content/en/PartnersList/partners.mdx +0 -22
- package/templates/content/en/Person/founder.mdx +0 -18
- package/templates/content/en/Pricing/pricing.mdx +0 -538
- package/templates/content/en/Pricing/pricing.schema.json +0 -209
- package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
- package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
- package/templates/content/en/Product/ailk.mdx +0 -19
- package/templates/content/en/Product/ailk.schema.json +0 -94
- package/templates/content/en/Product/api-launch-kit.mdx +0 -26
- package/templates/content/en/Product/launch-kit.mdx +0 -28
- package/templates/content/en/ProductsList/products.mdx +0 -26
- package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
- package/templates/content/en/ProjectsList/projects.mdx +0 -25
- package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
- package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
- package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
- package/templates/content/en/ResourcesHub/resources.mdx +0 -22
- package/templates/content/en/Service/consulting.mdx +0 -22
- package/templates/content/en/Service/implementation.mdx +0 -30
- package/templates/content/en/Service/launch-service.mdx +0 -232
- package/templates/content/en/Service/launch.mdx +0 -228
- package/templates/content/en/ServicesList/services.mdx +0 -84
- package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
- package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
- package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
- package/templates/content/en/TeamList/team.mdx +0 -63
- package/templates/content/en/TeamMember/founder.mdx +0 -72
- package/templates/content/en/TermsPage/terms.mdx +0 -19
- package/templates/content/en/TermsPage/terms.schema.json +0 -99
- package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
- package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
- package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
- package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
- package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
- package/templates/content/en-XA/AboutPage/about.mdx +0 -8
- package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
- package/templates/content/en-XA/HomePage/home.mdx +0 -8
- package/templates/content/es/_site.mdx +0 -30
- package/templates/content/fr/_site.mdx +0 -30
- package/templates/content/ja/_site.mdx +0 -30
- package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
- package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
- package/templates/content/pt-BR/HomePage/home.mdx +0 -23
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* @file me-route-precedence.test.ts
|
|
4
|
+
*
|
|
5
|
+
* The regression net for the one structural risk in #5295: the static
|
|
6
|
+
* `/v1/project-listings/me` and the parametric `/v1/project-listings/:id` share
|
|
7
|
+
* a prefix and are registered in DIFFERENT scopes — `/me` in `server.ts`'s
|
|
8
|
+
* `authScope`, `/:id` inside the encapsulated `tenantScope` behind
|
|
9
|
+
* `tenantPreHandlerPlugin`.
|
|
10
|
+
*
|
|
11
|
+
* `me.test.ts` registers `/me` alone, so nothing there would catch a
|
|
12
|
+
* registration reorder that shadowed `/me` or weakened the tenant guard on
|
|
13
|
+
* `/:id`. The security review for this change verified the behaviour by hand and
|
|
14
|
+
* named the missing net; this file is that net.
|
|
15
|
+
*
|
|
16
|
+
* It asserts three things in ONE Fastify instance with the REAL preHandlers:
|
|
17
|
+
* 1. `/v1/project-listings/me` reaches the `/me` handler — static beats
|
|
18
|
+
* parametric — and runs auth ONLY, no tenant guard.
|
|
19
|
+
* 2. `/v1/project-listings/<id>` still reaches the tenant handler WITH the
|
|
20
|
+
* tenant guard: registering a static sibling in the outer scope does not
|
|
21
|
+
* weaken it. A member below the `owner|admin` floor is still refused.
|
|
22
|
+
* 3. Registering both raises no duplicate-route error.
|
|
23
|
+
*
|
|
24
|
+
* This file is `oss: false` because it imports the multi-tenant slice
|
|
25
|
+
* (`middleware/tenant.js`, `./get.js`), the same reason `tenant-isolation.test.ts`
|
|
26
|
+
* is.
|
|
27
|
+
*/
|
|
28
|
+
import Fastify from "fastify";
|
|
29
|
+
import type { FastifyInstance } from "fastify";
|
|
30
|
+
|
|
31
|
+
jest.mock("@working-theory/email", () => ({
|
|
32
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
33
|
+
}));
|
|
34
|
+
jest.mock("@working-theory/license", () => ({
|
|
35
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
36
|
+
tier: "free",
|
|
37
|
+
plan: null,
|
|
38
|
+
features: [],
|
|
39
|
+
expiresAt: null,
|
|
40
|
+
revoked: false,
|
|
41
|
+
}),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
45
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
46
|
+
prisma: {
|
|
47
|
+
session: { findUnique: jest.fn() },
|
|
48
|
+
apiToken: {
|
|
49
|
+
findUnique: jest.fn(),
|
|
50
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
51
|
+
},
|
|
52
|
+
user: { findUnique: jest.fn() },
|
|
53
|
+
workspace: { findUnique: jest.fn(), count: jest.fn() },
|
|
54
|
+
membership: { findUnique: jest.fn() },
|
|
55
|
+
projectListing: { findMany: jest.fn(), findFirst: jest.fn() },
|
|
56
|
+
waitlistScore: { count: jest.fn() },
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
61
|
+
import { authPreHandlerPlugin } from "../../../middleware/auth.js";
|
|
62
|
+
import { tenantPreHandlerPlugin } from "../../../middleware/tenant.js";
|
|
63
|
+
import { projectListingMeRoute, projectListingTenantRoutes } from "../index.js";
|
|
64
|
+
|
|
65
|
+
const FRONTEND = "https://app.example.com";
|
|
66
|
+
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
|
|
67
|
+
|
|
68
|
+
const ORG = "org_00000000-0000-7000-8000-000000000001";
|
|
69
|
+
const WS = "wksp_00000000-0000-7000-8000-000000000001";
|
|
70
|
+
const LISTING_ID = "prjl_00000000-0000-7000-8000-0000000000d1";
|
|
71
|
+
|
|
72
|
+
/** `owner` clears the floor; `member` does not. Both are real members of ORG. */
|
|
73
|
+
const SESSION_ROWS: Record<string, Record<string, unknown>> = {
|
|
74
|
+
cookie_owner: {
|
|
75
|
+
id: "sess_owner",
|
|
76
|
+
userId: "owner-user",
|
|
77
|
+
expiresAt: FUTURE,
|
|
78
|
+
activeOrganizationId: ORG,
|
|
79
|
+
activeWorkspaceId: WS,
|
|
80
|
+
},
|
|
81
|
+
cookie_member: {
|
|
82
|
+
id: "sess_member",
|
|
83
|
+
userId: "member-user",
|
|
84
|
+
expiresAt: FUTURE,
|
|
85
|
+
activeOrganizationId: ORG,
|
|
86
|
+
activeWorkspaceId: WS,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const MEMBERSHIPS: Record<string, string> = {
|
|
91
|
+
"owner-user": "owner",
|
|
92
|
+
"member-user": "member",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
96
|
+
// The exact nesting server.ts uses: /me in the OUTER auth scope, the tenant
|
|
97
|
+
// routes in an INNER scope behind the tenant preHandler.
|
|
98
|
+
const app = Fastify();
|
|
99
|
+
await app.register(async (authScope) => {
|
|
100
|
+
await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
|
|
101
|
+
await authScope.register(projectListingMeRoute);
|
|
102
|
+
await authScope.register(async (tenantScope) => {
|
|
103
|
+
await tenantScope.register(tenantPreHandlerPlugin);
|
|
104
|
+
await tenantScope.register(projectListingTenantRoutes);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
await app.ready();
|
|
108
|
+
return app;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const cookie = (token: string) => ({ cookie: `better-auth.session_token=${token}` });
|
|
112
|
+
|
|
113
|
+
let app: FastifyInstance;
|
|
114
|
+
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
jest.clearAllMocks();
|
|
117
|
+
(prisma.session.findUnique as jest.Mock).mockImplementation(
|
|
118
|
+
({ where }: { where: { token?: string } }) =>
|
|
119
|
+
Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
|
|
120
|
+
);
|
|
121
|
+
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
|
|
122
|
+
(prisma.user.findUnique as jest.Mock).mockImplementation(({ where }: { where: { id: string } }) =>
|
|
123
|
+
Promise.resolve({ id: where.id, email: `${where.id}@example.com`, emailVerified: true }),
|
|
124
|
+
);
|
|
125
|
+
(prisma.workspace.findUnique as jest.Mock).mockResolvedValue({ id: WS, organizationId: ORG });
|
|
126
|
+
// The composite key is `organizationId_userId` — the order `tenant-context.ts`
|
|
127
|
+
// actually queries with.
|
|
128
|
+
(prisma.membership.findUnique as jest.Mock).mockImplementation(
|
|
129
|
+
({ where }: { where: { organizationId_userId?: { userId: string } } }) => {
|
|
130
|
+
const userId = where.organizationId_userId?.userId;
|
|
131
|
+
const role = userId ? MEMBERSHIPS[userId] : undefined;
|
|
132
|
+
return Promise.resolve(role ? { role } : null);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
// The /me read returns nothing; this file is about routing, not payloads.
|
|
136
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([]);
|
|
137
|
+
(prisma.projectListing.findFirst as jest.Mock).mockResolvedValue(null);
|
|
138
|
+
app = await buildApp();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
afterEach(async () => {
|
|
142
|
+
await app.close();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("route precedence: static /me beside parametric /:id in one instance", () => {
|
|
146
|
+
it("registering both raises no duplicate-route error", async () => {
|
|
147
|
+
// buildApp() already awaited app.ready(); reaching here is the assertion.
|
|
148
|
+
const tree = app.printRoutes();
|
|
149
|
+
expect(tree).toContain("me");
|
|
150
|
+
expect(tree).toContain(":id");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("/v1/project-listings/me reaches the /me handler, not the parametric one", async () => {
|
|
154
|
+
const res = await app.inject({
|
|
155
|
+
method: "GET",
|
|
156
|
+
url: "/v1/project-listings/me",
|
|
157
|
+
headers: cookie("cookie_owner"),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// The /me handler answers its own 200 + empty list. The parametric handler
|
|
161
|
+
// would have answered 400 `validation_failed` — "me" is not a listing id.
|
|
162
|
+
expect(res.statusCode).toBe(200);
|
|
163
|
+
expect(res.json()).toEqual({ ok: true, data: [] });
|
|
164
|
+
// And it read through the owner link, not by id.
|
|
165
|
+
expect(prisma.projectListing.findMany).toHaveBeenCalledWith(
|
|
166
|
+
expect.objectContaining({ where: { ownerUserId: "owner-user" } }),
|
|
167
|
+
);
|
|
168
|
+
expect(prisma.projectListing.findFirst).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("/me runs auth ONLY — a caller BELOW the owner|admin floor still gets their own rows", async () => {
|
|
172
|
+
const res = await app.inject({
|
|
173
|
+
method: "GET",
|
|
174
|
+
url: "/v1/project-listings/me",
|
|
175
|
+
headers: cookie("cookie_member"),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// A `member` is refused by the tenant routes and allowed here: /me is not
|
|
179
|
+
// behind the floor, and it does not need to be — the owner link is the gate.
|
|
180
|
+
expect(res.statusCode).toBe(200);
|
|
181
|
+
expect(res.json()).toEqual({ ok: true, data: [] });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("the parametric /:id still runs the tenant guard — the static sibling does not weaken it", async () => {
|
|
185
|
+
const ownerRes = await app.inject({
|
|
186
|
+
method: "GET",
|
|
187
|
+
url: `/v1/project-listings/${LISTING_ID}`,
|
|
188
|
+
headers: cookie("cookie_owner"),
|
|
189
|
+
});
|
|
190
|
+
// Owner clears the floor and reaches the service, which finds no row.
|
|
191
|
+
expect(ownerRes.statusCode).toBe(404);
|
|
192
|
+
|
|
193
|
+
const memberRes = await app.inject({
|
|
194
|
+
method: "GET",
|
|
195
|
+
url: `/v1/project-listings/${LISTING_ID}`,
|
|
196
|
+
headers: cookie("cookie_member"),
|
|
197
|
+
});
|
|
198
|
+
// Member is still refused BEFORE any read — the floor is intact.
|
|
199
|
+
expect(memberRes.statusCode).toBe(403);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("the tenant LIST route is unaffected and still refuses a member", async () => {
|
|
203
|
+
const res = await app.inject({
|
|
204
|
+
method: "GET",
|
|
205
|
+
url: "/v1/project-listings",
|
|
206
|
+
headers: cookie("cookie_member"),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(res.statusCode).toBe(403);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("an unauthenticated caller is refused on both paths", async () => {
|
|
213
|
+
const meRes = await app.inject({ method: "GET", url: "/v1/project-listings/me" });
|
|
214
|
+
const idRes = await app.inject({ method: "GET", url: `/v1/project-listings/${LISTING_ID}` });
|
|
215
|
+
|
|
216
|
+
expect(meRes.statusCode).toBe(401);
|
|
217
|
+
expect(idRes.statusCode).toBe(401);
|
|
218
|
+
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
219
|
+
expect(prisma.projectListing.findFirst).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file me.test.ts
|
|
3
|
+
*
|
|
4
|
+
* THE ACCEPTANCE GATE for the founder's own listings read (#5295).
|
|
5
|
+
* Registration mirrors server.ts: REAL `authPreHandlerPlugin ->
|
|
6
|
+
* projectListingMeRoute` (auth only, NO tenant guard), mocked `lib/prisma.js`.
|
|
7
|
+
*
|
|
8
|
+
* Proves the security properties the owner link exists for:
|
|
9
|
+
* - two users sharing a `founderEmail` value never see each other's rows;
|
|
10
|
+
* - a row whose owner link was never set is invisible to the person whose
|
|
11
|
+
* address it carries — the read never falls back to an email comparison;
|
|
12
|
+
* - the `where` clause carries `ownerUserId` and nothing resembling an email
|
|
13
|
+
* (a clause carrying one is treated as a contract violation and returns
|
|
14
|
+
* nothing, the `flow-checkouts` test's own discipline);
|
|
15
|
+
* - a signed-in caller who owns nothing gets `200 []`, never a 403;
|
|
16
|
+
* - `PROJECT_LISTING_ROLES` is untouched and this route never consults it.
|
|
17
|
+
*/
|
|
18
|
+
import Fastify from "fastify";
|
|
19
|
+
import type { FastifyInstance } from "fastify";
|
|
20
|
+
import { projectListingMeResponseSchema } from "@working-theory/validation";
|
|
21
|
+
|
|
22
|
+
// `services/project-listings.ts` imports `routeLead` from `lead-routing.js` at
|
|
23
|
+
// module scope, which transitively imports `@working-theory/email` and
|
|
24
|
+
// `@working-theory/license` — mocked here purely so that import chain resolves,
|
|
25
|
+
// mirroring `tenant-isolation.test.ts`'s discipline.
|
|
26
|
+
jest.mock("@working-theory/email", () => ({
|
|
27
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
28
|
+
}));
|
|
29
|
+
jest.mock("@working-theory/license", () => ({
|
|
30
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
31
|
+
tier: "free",
|
|
32
|
+
plan: null,
|
|
33
|
+
features: [],
|
|
34
|
+
expiresAt: null,
|
|
35
|
+
revoked: false,
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
40
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
41
|
+
prisma: {
|
|
42
|
+
session: { findUnique: jest.fn() },
|
|
43
|
+
apiToken: {
|
|
44
|
+
findUnique: jest.fn(),
|
|
45
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
46
|
+
},
|
|
47
|
+
user: { findUnique: jest.fn() },
|
|
48
|
+
projectListing: { findMany: jest.fn() },
|
|
49
|
+
waitlistScore: { count: jest.fn() },
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
54
|
+
import { authPreHandlerPlugin } from "../../../middleware/auth.js";
|
|
55
|
+
import { PROJECT_LISTING_ROLES } from "../../../services/project-listings.js";
|
|
56
|
+
import { projectListingMeRoute } from "../index.js";
|
|
57
|
+
|
|
58
|
+
const FRONTEND = "https://app.example.com";
|
|
59
|
+
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
|
|
60
|
+
|
|
61
|
+
const SITE_A = "wksp_00000000-0000-7000-8000-000000000001";
|
|
62
|
+
const SITE_B = "wksp_00000000-0000-7000-8000-000000000002";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `ada` and `bruno` are TWO DIFFERENT PEOPLE who typed the SAME address into
|
|
66
|
+
* the anonymous application form. That is the whole point: `founderEmail` is
|
|
67
|
+
* not an identity, and nothing may derive ownership from it.
|
|
68
|
+
*/
|
|
69
|
+
const SHARED_EMAIL = "founder@example.com";
|
|
70
|
+
|
|
71
|
+
const USERS: Record<string, { id: string; email: string; emailVerified: boolean }> = {
|
|
72
|
+
ada: { id: "ada", email: SHARED_EMAIL, emailVerified: true },
|
|
73
|
+
bruno: { id: "bruno", email: SHARED_EMAIL, emailVerified: true },
|
|
74
|
+
// Signed in, verified, owns nothing at all.
|
|
75
|
+
cleo: { id: "cleo", email: "cleo@example.com", emailVerified: true },
|
|
76
|
+
// Signed in with an address that was never verified, and whose own listing
|
|
77
|
+
// WAS linked earlier. See the test that covers this case.
|
|
78
|
+
dane: { id: "dane", email: "dane@example.com", emailVerified: false },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const SESSION_ROWS: Record<string, Record<string, unknown>> = {
|
|
82
|
+
cookie_ada: { id: "sess_ada", userId: "ada", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
83
|
+
cookie_bruno: { id: "sess_bruno", userId: "bruno", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
84
|
+
cookie_cleo: { id: "sess_cleo", userId: "cleo", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
85
|
+
cookie_dane: { id: "sess_dane", userId: "dane", expiresAt: FUTURE, activeOrganizationId: null, activeWorkspaceId: null },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type ListingFixture = {
|
|
89
|
+
id: string;
|
|
90
|
+
siteId: string;
|
|
91
|
+
founderEmail: string;
|
|
92
|
+
ownerUserId: string | null;
|
|
93
|
+
experimentKey: string | null;
|
|
94
|
+
createdAt: Date;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** A complete stored row at `PROJECT_LISTING_SELECT`'s shape. */
|
|
98
|
+
function row(fixture: ListingFixture) {
|
|
99
|
+
return {
|
|
100
|
+
id: fixture.id,
|
|
101
|
+
siteId: fixture.siteId,
|
|
102
|
+
leadId: `lead_${fixture.id}`,
|
|
103
|
+
slug: fixture.id.replace("prjl_", ""),
|
|
104
|
+
name: "Atlas",
|
|
105
|
+
oneLiner: "A one-liner.",
|
|
106
|
+
problem: "A problem.",
|
|
107
|
+
url: "https://atlas.example.com",
|
|
108
|
+
product: "live-some-users",
|
|
109
|
+
commitment: "full-time",
|
|
110
|
+
teamComposition: "just-me",
|
|
111
|
+
faqs: null,
|
|
112
|
+
anythingElse: null,
|
|
113
|
+
address: "1 Main St",
|
|
114
|
+
addressLine1: null,
|
|
115
|
+
addressLine2: null,
|
|
116
|
+
addressCity: null,
|
|
117
|
+
addressRegion: null,
|
|
118
|
+
addressPostalCode: null,
|
|
119
|
+
addressCountry: null,
|
|
120
|
+
questions: null,
|
|
121
|
+
siteAnswers: null,
|
|
122
|
+
windowDays: 30,
|
|
123
|
+
founderName: "A Founder",
|
|
124
|
+
founderEmail: fixture.founderEmail,
|
|
125
|
+
founderCompany: null,
|
|
126
|
+
headline: "Atlas",
|
|
127
|
+
logoMarkUrl: null,
|
|
128
|
+
logoWordmarkUrl: null,
|
|
129
|
+
featured: false,
|
|
130
|
+
status: "requested",
|
|
131
|
+
consentAt: fixture.createdAt,
|
|
132
|
+
decidedAt: null,
|
|
133
|
+
decidedBy: "operator@example.com",
|
|
134
|
+
issueUrl: "https://github.com/example/repo/issues/1",
|
|
135
|
+
experimentKey: fixture.experimentKey,
|
|
136
|
+
windowEndsAt: null,
|
|
137
|
+
createdAt: fixture.createdAt,
|
|
138
|
+
updatedAt: fixture.createdAt,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const LISTINGS: ListingFixture[] = [
|
|
143
|
+
// ada's own, linked to ada.
|
|
144
|
+
{
|
|
145
|
+
id: "prjl_ada_one",
|
|
146
|
+
siteId: SITE_A,
|
|
147
|
+
founderEmail: SHARED_EMAIL,
|
|
148
|
+
ownerUserId: "ada",
|
|
149
|
+
experimentKey: "atlas-waitlist",
|
|
150
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
151
|
+
},
|
|
152
|
+
// ada again, NEWER, and in a SECOND workspace of ada's — proves the per-site
|
|
153
|
+
// waitlistCount resolution and the newest-first ordering.
|
|
154
|
+
{
|
|
155
|
+
id: "prjl_ada_two",
|
|
156
|
+
siteId: SITE_B,
|
|
157
|
+
founderEmail: SHARED_EMAIL,
|
|
158
|
+
ownerUserId: "ada",
|
|
159
|
+
experimentKey: "atlas-two-waitlist",
|
|
160
|
+
createdAt: new Date("2026-09-05T00:00:00.000Z"),
|
|
161
|
+
},
|
|
162
|
+
// bruno's own — SAME founderEmail as ada's rows, different person.
|
|
163
|
+
{
|
|
164
|
+
id: "prjl_bruno_one",
|
|
165
|
+
siteId: SITE_A,
|
|
166
|
+
founderEmail: SHARED_EMAIL,
|
|
167
|
+
ownerUserId: "bruno",
|
|
168
|
+
experimentKey: null,
|
|
169
|
+
createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
170
|
+
},
|
|
171
|
+
// Carries the shared address and was NEVER linked. Invisible to everyone.
|
|
172
|
+
{
|
|
173
|
+
id: "prjl_unlinked",
|
|
174
|
+
siteId: SITE_A,
|
|
175
|
+
founderEmail: SHARED_EMAIL,
|
|
176
|
+
ownerUserId: null,
|
|
177
|
+
experimentKey: null,
|
|
178
|
+
createdAt: new Date("2026-09-04T00:00:00.000Z"),
|
|
179
|
+
},
|
|
180
|
+
// dane's own, linked while dane's address was still verified.
|
|
181
|
+
{
|
|
182
|
+
id: "prjl_dane_one",
|
|
183
|
+
siteId: SITE_A,
|
|
184
|
+
founderEmail: "dane@example.com",
|
|
185
|
+
ownerUserId: "dane",
|
|
186
|
+
experimentKey: null,
|
|
187
|
+
createdAt: new Date("2026-09-02T00:00:00.000Z"),
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
type ListingWhere = Record<string, unknown> & { ownerUserId?: unknown };
|
|
192
|
+
|
|
193
|
+
const WAITLIST_COUNTS: Record<string, Record<string, number>> = {
|
|
194
|
+
[SITE_A]: { "atlas-waitlist": 7 },
|
|
195
|
+
[SITE_B]: { "atlas-two-waitlist": 3 },
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function installFixtureMocks(): void {
|
|
199
|
+
(prisma.session.findUnique as jest.Mock).mockImplementation(
|
|
200
|
+
({ where }: { where: { token?: string } }) =>
|
|
201
|
+
Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
|
|
202
|
+
);
|
|
203
|
+
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
|
|
204
|
+
(prisma.user.findUnique as jest.Mock).mockImplementation(({ where }: { where: { id: string } }) =>
|
|
205
|
+
Promise.resolve(USERS[where.id] ?? null),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The owner link is the ONLY join the service may use, and this mock is
|
|
210
|
+
* written to FAIL on a regression rather than to absorb one.
|
|
211
|
+
*
|
|
212
|
+
* Two earlier, weaker forms were rejected during review, both of which let a
|
|
213
|
+
* regression keep the security tests green:
|
|
214
|
+
*
|
|
215
|
+
* - `expect(where).not.toHaveProperty("founderEmail")` inspects TOP-LEVEL
|
|
216
|
+
* keys only, so an `OR`-wrapped email fallback
|
|
217
|
+
* (`{ OR: [{ ownerUserId }, { founderEmail }] }`) sails straight past it —
|
|
218
|
+
* and a mock that then filtered on `where.ownerUserId` returns `[]` for
|
|
219
|
+
* that shape, which looks like "nothing leaked".
|
|
220
|
+
* - Filtering on `where.ownerUserId` when it is `undefined` also returns
|
|
221
|
+
* `[]`, while Prisma compiles an `undefined` filter to no filter at all
|
|
222
|
+
* (`WHERE 1=1`) and the REAL read would be unscoped.
|
|
223
|
+
*
|
|
224
|
+
* So: any key other than `ownerUserId` THROWS, an `ownerUserId` that is not a
|
|
225
|
+
* string THROWS, and the filter is exact equality on it. A widened read now
|
|
226
|
+
* fails as a thrown error rather than as a quietly empty result.
|
|
227
|
+
*/
|
|
228
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
229
|
+
({ where }: { where: ListingWhere }) => {
|
|
230
|
+
const keys = Object.keys(where);
|
|
231
|
+
if (keys.length !== 1 || keys[0] !== "ownerUserId") {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`the /me read must scope on ownerUserId and nothing else; got where keys [${keys.join(", ")}]`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (typeof where.ownerUserId !== "string") {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"ownerUserId must be a concrete string; Prisma compiles an undefined filter to no filter, leaving the read unscoped",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const ownerUserId = where.ownerUserId;
|
|
242
|
+
return Promise.resolve(
|
|
243
|
+
LISTINGS.filter((l) => l.ownerUserId === ownerUserId)
|
|
244
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
245
|
+
.map(row),
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
(prisma.waitlistScore.count as jest.Mock).mockImplementation(
|
|
251
|
+
({ where }: { where: { siteId: string; experimentKey: string } }) =>
|
|
252
|
+
Promise.resolve(WAITLIST_COUNTS[where.siteId]?.[where.experimentKey] ?? 0),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
257
|
+
const app = Fastify();
|
|
258
|
+
await app.register(async (authScope) => {
|
|
259
|
+
await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
|
|
260
|
+
await authScope.register(projectListingMeRoute);
|
|
261
|
+
});
|
|
262
|
+
await app.ready();
|
|
263
|
+
return app;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const cookie = (token: string) => ({ cookie: `better-auth.session_token=${token}` });
|
|
267
|
+
const URL_ME = "/v1/project-listings/me";
|
|
268
|
+
|
|
269
|
+
const listingWhere = (call = 0) =>
|
|
270
|
+
(prisma.projectListing.findMany as jest.Mock).mock.calls[call][0].where as ListingWhere;
|
|
271
|
+
|
|
272
|
+
let app: FastifyInstance;
|
|
273
|
+
|
|
274
|
+
beforeEach(async () => {
|
|
275
|
+
jest.clearAllMocks();
|
|
276
|
+
installFixtureMocks();
|
|
277
|
+
app = await buildApp();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
afterEach(async () => {
|
|
281
|
+
await app.close();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("GET /v1/project-listings/me — auth", () => {
|
|
285
|
+
it("no session cookie and no bearer → the preHandler's existing 401; no listing read", async () => {
|
|
286
|
+
const res = await app.inject({ method: "GET", url: URL_ME });
|
|
287
|
+
|
|
288
|
+
expect(res.statusCode).toBe(401);
|
|
289
|
+
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("an expired / unknown cookie → 401", async () => {
|
|
293
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_nobody") });
|
|
294
|
+
|
|
295
|
+
expect(res.statusCode).toBe(401);
|
|
296
|
+
expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("GET /v1/project-listings/me — the owner link is the only join", () => {
|
|
301
|
+
/** SECURITY PROPERTY: two users sharing a founderEmail value. */
|
|
302
|
+
it("two users sharing a founderEmail never see each other's listings", async () => {
|
|
303
|
+
const adaRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
304
|
+
const brunoRes = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
|
|
305
|
+
|
|
306
|
+
expect(adaRes.statusCode).toBe(200);
|
|
307
|
+
expect(brunoRes.statusCode).toBe(200);
|
|
308
|
+
|
|
309
|
+
const adaIds = (adaRes.json().data as Array<{ id: string }>).map((r) => r.id);
|
|
310
|
+
const brunoIds = (brunoRes.json().data as Array<{ id: string }>).map((r) => r.id);
|
|
311
|
+
|
|
312
|
+
// ada sees her two rows, newest first, and NOT bruno's.
|
|
313
|
+
expect(adaIds).toEqual(["prjl_ada_two", "prjl_ada_one"]);
|
|
314
|
+
// bruno sees his one row, and NOT ada's — though every row in play carries
|
|
315
|
+
// the identical founderEmail.
|
|
316
|
+
expect(brunoIds).toEqual(["prjl_bruno_one"]);
|
|
317
|
+
|
|
318
|
+
expect(adaIds).not.toContain("prjl_bruno_one");
|
|
319
|
+
expect(brunoIds).not.toContain("prjl_ada_one");
|
|
320
|
+
expect(brunoIds).not.toContain("prjl_ada_two");
|
|
321
|
+
// And neither of them reaches the un-linked row carrying the same address.
|
|
322
|
+
expect([...adaIds, ...brunoIds]).not.toContain("prjl_unlinked");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/** SECURITY PROPERTY: no read-time email fallback. */
|
|
326
|
+
it("a listing carrying the caller's own address but NO owner link is never returned", async () => {
|
|
327
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
328
|
+
|
|
329
|
+
const ids = (res.json().data as Array<{ id: string }>).map((r) => r.id);
|
|
330
|
+
expect(ids).not.toContain("prjl_unlinked");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("the where clause is the owner link and carries nothing email-shaped", async () => {
|
|
334
|
+
await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
335
|
+
|
|
336
|
+
expect(listingWhere()).toEqual({ ownerUserId: "ada" });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("a query parameter is never read — ?email= and ?ownerUserId= change nothing", async () => {
|
|
340
|
+
const res = await app.inject({
|
|
341
|
+
method: "GET",
|
|
342
|
+
url: `${URL_ME}?email=${encodeURIComponent(SHARED_EMAIL)}&ownerUserId=bruno`,
|
|
343
|
+
headers: cookie("cookie_ada"),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(res.statusCode).toBe(200);
|
|
347
|
+
// Still ada's own rows, scoped by the SESSION's user id.
|
|
348
|
+
expect(listingWhere()).toEqual({ ownerUserId: "ada" });
|
|
349
|
+
expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
|
|
350
|
+
"prjl_ada_two",
|
|
351
|
+
"prjl_ada_one",
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("GET /v1/project-listings/me — the empty case is not a refusal", () => {
|
|
357
|
+
/** SECURITY / UX PROPERTY: owning nothing is an empty list, never a 403. */
|
|
358
|
+
it("a signed-in caller who owns no listing gets 200 with an empty list, not a 403", async () => {
|
|
359
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
|
|
360
|
+
|
|
361
|
+
expect(res.statusCode).toBe(200);
|
|
362
|
+
expect(res.json()).toEqual({ ok: true, data: [] });
|
|
363
|
+
// Specifically NOT the tenant routes' 403 — cleo is legitimately signed in.
|
|
364
|
+
expect(res.statusCode).not.toBe(403);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("no waitlist count is read when the caller owns nothing", async () => {
|
|
368
|
+
await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_cleo") });
|
|
369
|
+
|
|
370
|
+
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe("GET /v1/project-listings/me — the response shape", () => {
|
|
375
|
+
it("every row parses under projectListingMeResponseSchema", async () => {
|
|
376
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
377
|
+
|
|
378
|
+
expect(res.statusCode).toBe(200);
|
|
379
|
+
const parsed = projectListingMeResponseSchema.safeParse(res.json().data);
|
|
380
|
+
expect(parsed.success).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("the five internal references never reach the founder", async () => {
|
|
384
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
385
|
+
|
|
386
|
+
for (const listing of res.json().data as Array<Record<string, unknown>>) {
|
|
387
|
+
expect(listing).not.toHaveProperty("siteId");
|
|
388
|
+
expect(listing).not.toHaveProperty("leadId");
|
|
389
|
+
expect(listing).not.toHaveProperty("decidedBy");
|
|
390
|
+
expect(listing).not.toHaveProperty("issueUrl");
|
|
391
|
+
expect(listing).not.toHaveProperty("experimentKey");
|
|
392
|
+
}
|
|
393
|
+
// No workspace id reaches the wire anywhere in the body.
|
|
394
|
+
expect(res.body).not.toContain(SITE_A);
|
|
395
|
+
expect(res.body).not.toContain(SITE_B);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("waitlistCount is resolved per site, not all under one site id", async () => {
|
|
399
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
400
|
+
|
|
401
|
+
const byId = Object.fromEntries(
|
|
402
|
+
(res.json().data as Array<{ id: string; waitlistCount: number }>).map((r) => [
|
|
403
|
+
r.id,
|
|
404
|
+
r.waitlistCount,
|
|
405
|
+
]),
|
|
406
|
+
);
|
|
407
|
+
// prjl_ada_one's experiment lives at SITE_A (7); prjl_ada_two's at SITE_B (3).
|
|
408
|
+
// One shared site id would have reported 0 for one of them.
|
|
409
|
+
expect(byId["prjl_ada_one"]).toBe(7);
|
|
410
|
+
expect(byId["prjl_ada_two"]).toBe(3);
|
|
411
|
+
expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
|
|
412
|
+
where: { siteId: SITE_A, experimentKey: "atlas-waitlist" },
|
|
413
|
+
});
|
|
414
|
+
expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
|
|
415
|
+
where: { siteId: SITE_B, experimentKey: "atlas-two-waitlist" },
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("the founder's own email is echoed back to the founder who wrote it", async () => {
|
|
420
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_bruno") });
|
|
421
|
+
|
|
422
|
+
expect((res.json().data as Array<{ founderEmail: string }>)[0]?.founderEmail).toBe(
|
|
423
|
+
SHARED_EMAIL,
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("GET /v1/project-listings/me — the read-time verification posture", () => {
|
|
429
|
+
/**
|
|
430
|
+
* A caller whose address is no longer verified still reads the rows their
|
|
431
|
+
* link already covers. This is deliberate, and it is the one place this route
|
|
432
|
+
* diverges from `/v1/flow-checkouts/me`, which re-checks `emailVerified`.
|
|
433
|
+
*
|
|
434
|
+
* The re-check is load-bearing on `/v1/waitlist-signups/me`, where the match
|
|
435
|
+
* IS an email, and it is defence in depth on `/v1/flow-checkouts/me`. Here it
|
|
436
|
+
* would be neither: the link is the proof, it was only ever issuable under
|
|
437
|
+
* verification, and refusing on a later unverified address would hide rows
|
|
438
|
+
* the caller already proved they own (changing your email address is not a
|
|
439
|
+
* reason to lose your own submissions). An unverified address still cannot
|
|
440
|
+
* CREATE a link — `listing-promotion.ts` refuses before any listing read.
|
|
441
|
+
*/
|
|
442
|
+
it("a caller whose address is no longer verified still reads the rows their link already covers", async () => {
|
|
443
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_dane") });
|
|
444
|
+
|
|
445
|
+
expect(res.statusCode).toBe(200);
|
|
446
|
+
expect((res.json().data as Array<{ id: string }>).map((r) => r.id)).toEqual([
|
|
447
|
+
"prjl_dane_one",
|
|
448
|
+
]);
|
|
449
|
+
expect(listingWhere()).toEqual({ ownerUserId: "dane" });
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe("the existing operator role floor is untouched", () => {
|
|
454
|
+
it("PROJECT_LISTING_ROLES is still exactly owner + admin", () => {
|
|
455
|
+
expect([...PROJECT_LISTING_ROLES].sort()).toEqual(["admin", "owner"]);
|
|
456
|
+
expect(PROJECT_LISTING_ROLES.size).toBe(2);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("the /me route never consults the tenant context or the role floor", async () => {
|
|
460
|
+
// The app this suite builds registers NO tenant preHandler at all, so
|
|
461
|
+
// `request.tenant` is undefined for every request above. That every test in
|
|
462
|
+
// this file answers 200 is the proof: a route that consulted the floor
|
|
463
|
+
// would have answered 401/403 instead.
|
|
464
|
+
const res = await app.inject({ method: "GET", url: URL_ME, headers: cookie("cookie_ada") });
|
|
465
|
+
|
|
466
|
+
expect(res.statusCode).toBe(200);
|
|
467
|
+
});
|
|
468
|
+
});
|