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,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file slack-notify.ts
|
|
3
|
+
* @description The operator's one free action per application (#4999): post
|
|
4
|
+
* a new `ProjectListing` to the site's Slack channel with Approve/Reject
|
|
5
|
+
* buttons, then update that SAME message in place once a decision lands.
|
|
6
|
+
*
|
|
7
|
+
* A thin `fetch` client over the Slack Web API — no Slack SDK (this
|
|
8
|
+
* environment's contract). `AILK_SLACK_BOT_TOKEN`/`AILK_SLACK_CHANNEL_ID`
|
|
9
|
+
* absent means Slack is off: both exports become no-ops that log
|
|
10
|
+
* `slack-skipped` (id only) and never throw — a create must never fail
|
|
11
|
+
* because Slack is unconfigured (or down), and a decision must always be
|
|
12
|
+
* appliable even if the outcome can't be posted back.
|
|
13
|
+
*
|
|
14
|
+
* PII boundary: only `listingId` (plus a Slack API `error` code, never a
|
|
15
|
+
* response body) ever reaches a log line here — never `founderEmail`,
|
|
16
|
+
* `founderName`, `oneLiner`, `problem`, or any other applicant-authored text.
|
|
17
|
+
* The Block Kit body sent TO Slack necessarily carries that text (that's the
|
|
18
|
+
* point of the notification) — Slack, not this process's own logs, is the
|
|
19
|
+
* destination for it.
|
|
20
|
+
*
|
|
21
|
+
* Security note (PR #4999 review): every applicant-authored string
|
|
22
|
+
* (`oneLiner`/`problem`/`founderName`/`founderCompany`/`url`) is
|
|
23
|
+
* submitted by an ANONYMOUS, unauthenticated caller (`POST
|
|
24
|
+
* /v1/project-listings`) and lands in `mrkdwn` fields, which Slack parses for
|
|
25
|
+
* `<!channel>`/`<!here>`/`<@U…>` mentions and `<url|label>` masked links — an
|
|
26
|
+
* applicant could otherwise mass-ping the channel or mask a phishing link
|
|
27
|
+
* behind innocuous-looking text. `escapeMrkdwn` (Slack's own documented
|
|
28
|
+
* escaping: `&` -> `&`, `<` -> `<`, `>` -> `>`, in that order) runs
|
|
29
|
+
* on every such string, and on the Slack-supplied `decidedByName` too (lower
|
|
30
|
+
* risk — only a member of the notified channel controls it — but the same
|
|
31
|
+
* fix applies for defense in depth).
|
|
32
|
+
*/
|
|
33
|
+
import { logger } from "@working-theory/observability";
|
|
34
|
+
|
|
35
|
+
import { config } from "../config/index.js";
|
|
36
|
+
import { prisma } from "../lib/prisma.js";
|
|
37
|
+
|
|
38
|
+
const SLACK_API_BASE = "https://slack.com/api";
|
|
39
|
+
|
|
40
|
+
/** The subset of a ProjectListing this module ever renders into a Slack message. */
|
|
41
|
+
export type ProjectListingSlackInfo = {
|
|
42
|
+
id: string;
|
|
43
|
+
/**
|
|
44
|
+
* Nullable since #5036 D1: every application field is nullable at rest so a
|
|
45
|
+
* half-filled draft is storable. A listing only reaches Slack once it has
|
|
46
|
+
* been SUBMITTED, so in practice these are set — the nulls are rendered as
|
|
47
|
+
* an em dash rather than asserted away, because a `!` here would turn a data
|
|
48
|
+
* surprise into a crash inside a notifier that is not allowed to throw.
|
|
49
|
+
*/
|
|
50
|
+
name: string | null;
|
|
51
|
+
oneLiner: string | null;
|
|
52
|
+
problem: string | null;
|
|
53
|
+
url: string | null;
|
|
54
|
+
/** The six-rung product ladder that replaced `stage` (#5036 D2). */
|
|
55
|
+
product: string | null;
|
|
56
|
+
founderName: string;
|
|
57
|
+
founderCompany: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** What an absent field renders as in a Slack field. */
|
|
61
|
+
const ABSENT = "—";
|
|
62
|
+
|
|
63
|
+
type SlackApiResult = {
|
|
64
|
+
ok: boolean;
|
|
65
|
+
error?: string;
|
|
66
|
+
ts?: string;
|
|
67
|
+
channel?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Slack's own documented `mrkdwn` escaping — `&` first, then `<`/`>` — so an
|
|
72
|
+
* applicant-authored string can never open a `<!channel>`/`<@U…>` mention or
|
|
73
|
+
* a `<url|label>` masked link inside a `mrkdwn` field.
|
|
74
|
+
*/
|
|
75
|
+
function escapeMrkdwn(value: string): string {
|
|
76
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function callSlackApi(
|
|
80
|
+
token: string,
|
|
81
|
+
method: "chat.postMessage" | "chat.update",
|
|
82
|
+
body: Record<string, unknown>,
|
|
83
|
+
): Promise<SlackApiResult> {
|
|
84
|
+
const res = await fetch(`${SLACK_API_BASE}/${method}`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"content-type": "application/json; charset=utf-8",
|
|
88
|
+
authorization: `Bearer ${token}`,
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
});
|
|
92
|
+
return (await res.json()) as SlackApiResult;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The header + info-fields blocks shared by the initial post and the decided
|
|
97
|
+
* update. Every applicant-authored string is `escapeMrkdwn`-ed before it
|
|
98
|
+
* enters a `mrkdwn` field (see the module doc's security note) — an
|
|
99
|
+
* anonymous applicant's `oneLiner`/`problem`/`founderName`/
|
|
100
|
+
* `founderCompany`/`url` can otherwise inject a Slack mention or a masked
|
|
101
|
+
* phishing link. `listing.name` is NOT escaped here — it renders in the
|
|
102
|
+
* `header` block as `plain_text`, which Slack never interprets as markup.
|
|
103
|
+
*/
|
|
104
|
+
function infoBlocks(listing: ProjectListingSlackInfo): unknown[] {
|
|
105
|
+
const fields = [
|
|
106
|
+
{ type: "mrkdwn", text: `*One-liner:*\n${escapeMrkdwn(listing.oneLiner ?? ABSENT)}` },
|
|
107
|
+
{ type: "mrkdwn", text: `*Problem:*\n${escapeMrkdwn(listing.problem ?? ABSENT)}` },
|
|
108
|
+
{ type: "mrkdwn", text: `*Product:*\n${escapeMrkdwn(listing.product ?? ABSENT)}` },
|
|
109
|
+
...(listing.url ? [{ type: "mrkdwn", text: `*URL:*\n${escapeMrkdwn(listing.url)}` }] : []),
|
|
110
|
+
{
|
|
111
|
+
type: "mrkdwn",
|
|
112
|
+
text: `*Founder:*\n${escapeMrkdwn(listing.founderName)}${
|
|
113
|
+
listing.founderCompany ? ` (${escapeMrkdwn(listing.founderCompany)})` : ""
|
|
114
|
+
}`,
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
{ type: "header", text: { type: "plain_text", text: listing.name ?? ABSENT, emoji: true } },
|
|
120
|
+
{ type: "section", fields },
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Posts the new application to Slack (`chat.postMessage`) with Approve/Reject
|
|
126
|
+
* buttons carrying the listing id as `value`. On success, stamps the
|
|
127
|
+
* returned `channel`/`ts` onto the row so `updateListingMessage` can later
|
|
128
|
+
* target the same message. No-op (logs `slack-skipped`) when Slack env is
|
|
129
|
+
* absent; never throws — a Slack failure (missing config, API error, network
|
|
130
|
+
* error) never fails the create that already succeeded.
|
|
131
|
+
*/
|
|
132
|
+
export async function postListingApplication(listing: ProjectListingSlackInfo): Promise<void> {
|
|
133
|
+
const token = config.AILK_SLACK_BOT_TOKEN;
|
|
134
|
+
const channel = config.AILK_SLACK_CHANNEL_ID;
|
|
135
|
+
if (!token || !channel) {
|
|
136
|
+
logger.info({ listingId: listing.id }, "slack-skipped");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const result = await callSlackApi(token, "chat.postMessage", {
|
|
142
|
+
channel,
|
|
143
|
+
// `text:` is mrkdwn-parsed and is what Slack shows in the notification
|
|
144
|
+
// fallback, so the applicant-authored name is escaped here even though
|
|
145
|
+
// the same value is safe unescaped in the `plain_text` header block
|
|
146
|
+
// (PR #5042 security review, finding 5).
|
|
147
|
+
text: `New project application: ${escapeMrkdwn(listing.name ?? ABSENT)}`,
|
|
148
|
+
blocks: [
|
|
149
|
+
...infoBlocks(listing),
|
|
150
|
+
{
|
|
151
|
+
type: "actions",
|
|
152
|
+
block_id: "listing_decision",
|
|
153
|
+
elements: [
|
|
154
|
+
{
|
|
155
|
+
type: "button",
|
|
156
|
+
text: { type: "plain_text", text: "Approve", emoji: true },
|
|
157
|
+
style: "primary",
|
|
158
|
+
action_id: "listing_approve",
|
|
159
|
+
value: listing.id,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: "button",
|
|
163
|
+
text: { type: "plain_text", text: "Reject", emoji: true },
|
|
164
|
+
style: "danger",
|
|
165
|
+
action_id: "listing_reject",
|
|
166
|
+
value: listing.id,
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!result.ok) {
|
|
174
|
+
logger.warn({ listingId: listing.id, slackError: result.error }, "slack post failed");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await prisma.projectListing.update({
|
|
179
|
+
where: { id: listing.id },
|
|
180
|
+
data: { slackChannel: result.channel ?? channel, slackTs: result.ts ?? null },
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
logger.warn(
|
|
184
|
+
{ listingId: listing.id, errName: err instanceof Error ? err.name : "UnknownError" },
|
|
185
|
+
"slack post threw",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Replaces the posted message's actions block with an outcome line
|
|
192
|
+
* (`chat.update`) — same header + info fields, Approve/Reject buttons gone.
|
|
193
|
+
* No-op (logs `slack-skipped`) when Slack env is absent OR the row never
|
|
194
|
+
* carries `slackChannel`/`slackTs` (the post was skipped or failed); never
|
|
195
|
+
* throws — the decision has already been applied by the time this runs.
|
|
196
|
+
*/
|
|
197
|
+
export async function updateListingMessage(
|
|
198
|
+
listing: ProjectListingSlackInfo & { slackChannel: string | null; slackTs: string | null },
|
|
199
|
+
/** The stored decision status (#5033 — `active` is the approved state). */
|
|
200
|
+
outcome: "active" | "rejected",
|
|
201
|
+
decidedByName: string,
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
const token = config.AILK_SLACK_BOT_TOKEN;
|
|
204
|
+
if (!token || !listing.slackChannel || !listing.slackTs) {
|
|
205
|
+
logger.info({ listingId: listing.id }, "slack-skipped");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// decidedByName is Slack-supplied (payload.user.username/name) — escaped
|
|
210
|
+
// for the same reason applicant text is (see the module doc's security
|
|
211
|
+
// note), even though only a member of the notified channel controls it.
|
|
212
|
+
const safeDecidedByName = escapeMrkdwn(decidedByName);
|
|
213
|
+
// The operator-facing copy stays "Approved" — the vocabulary change is in
|
|
214
|
+
// the stored status, not in what a human reads in the channel.
|
|
215
|
+
const outcomeLine =
|
|
216
|
+
outcome === "active"
|
|
217
|
+
? `:white_check_mark: Approved by ${safeDecidedByName}`
|
|
218
|
+
: `:x: Rejected by ${safeDecidedByName}`;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const result = await callSlackApi(token, "chat.update", {
|
|
222
|
+
channel: listing.slackChannel,
|
|
223
|
+
ts: listing.slackTs,
|
|
224
|
+
text: `${escapeMrkdwn(listing.name ?? ABSENT)} — ${outcomeLine}`,
|
|
225
|
+
blocks: [
|
|
226
|
+
...infoBlocks(listing),
|
|
227
|
+
{ type: "section", text: { type: "mrkdwn", text: outcomeLine } },
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!result.ok) {
|
|
232
|
+
logger.warn({ listingId: listing.id, slackError: result.error }, "slack update failed");
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logger.warn(
|
|
236
|
+
{ listingId: listing.id, errName: err instanceof Error ? err.name : "UnknownError" },
|
|
237
|
+
"slack update threw",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* Tenant-context service — the multi-tenant module's guard + tenancy assembly
|
|
4
|
+
* (FEAT-042, design decision (a): row-level isolation at ONE data-access
|
|
5
|
+
* boundary + a default-deny scope layer + a guard).
|
|
6
|
+
*
|
|
7
|
+
* This module and `../lib/tenant-db.ts` are the ONLY two files allowed to
|
|
8
|
+
* touch tenancy/domain Prisma models directly (enforced by the
|
|
9
|
+
* `forbidden_pattern` tenant-scope rule in architecture.yaml). Everything
|
|
10
|
+
* here follows the ADR 0009 D3 service-layer convention: cross-namespace
|
|
11
|
+
* assembly (auth-namespace Membership/Workspace/Session ↔ content-namespace
|
|
12
|
+
* AgentSession/Artifact) happens only in `apps/api/src/services/`.
|
|
13
|
+
*
|
|
14
|
+
* The guard model (default-deny — every branch that is not an explicit grant
|
|
15
|
+
* is a denial):
|
|
16
|
+
*
|
|
17
|
+
* 1. `resolveActiveTenant` — validates the caller's OWN session context:
|
|
18
|
+
* cookie session present (bearer tokens carry no workspace binding —
|
|
19
|
+
* #3591), an active workspace set, the workspace row exists, the
|
|
20
|
+
* session's activeOrganizationId matches the workspace's organization,
|
|
21
|
+
* and a Membership(user, org) row exists. Result: a `TenantContext`
|
|
22
|
+
* every downstream domain query is scoped by.
|
|
23
|
+
* 2. Workspace-target routes compare the URL's workspaceId against the
|
|
24
|
+
* resolved context — mismatch is `forbidden` WITHOUT looking the target
|
|
25
|
+
* up (no tenant enumeration).
|
|
26
|
+
* 3. Session/artifact-target routes never load the target unscoped at the
|
|
27
|
+
* route layer; `tenant-db.ts` scopes every lookup by workspaceId, so a
|
|
28
|
+
* foreign target is indistinguishable from a missing one (`not_found`).
|
|
29
|
+
*
|
|
30
|
+
* Service results use the discriminated `{ ok } | { ok: false, error }`
|
|
31
|
+
* convention from `lead-routing.ts`; routes translate kinds to HTTP statuses.
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_ORG_ID,
|
|
35
|
+
DEFAULT_WORKSPACE_ID,
|
|
36
|
+
OPERATOR_MEMBERSHIP_ID,
|
|
37
|
+
} from "@working-theory/database/tenancy";
|
|
38
|
+
import { isSentinel, type Id } from "@working-theory/ids";
|
|
39
|
+
|
|
40
|
+
import { prisma } from "../lib/prisma.js";
|
|
41
|
+
import type { SessionTenant } from "../middleware/auth.js";
|
|
42
|
+
|
|
43
|
+
export type MembershipRole = "owner" | "admin" | "member";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The tenant scope every domain query is bound to. Ids carry the ADR-0031
|
|
47
|
+
* brand; values originating from Prisma reads are brand-asserted at the read
|
|
48
|
+
* edge (the DB CHECK constraints are the runtime guarantee).
|
|
49
|
+
*/
|
|
50
|
+
export interface TenantContext {
|
|
51
|
+
userId: Id<"user">;
|
|
52
|
+
organizationId: Id<"organization">;
|
|
53
|
+
workspaceId: Id<"workspace">;
|
|
54
|
+
role: MembershipRole;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TenantErrorKind = "forbidden" | "not_found" | "conflict";
|
|
58
|
+
|
|
59
|
+
export type TenantResult<T> =
|
|
60
|
+
| { ok: true; value: T }
|
|
61
|
+
| { ok: false; error: { kind: TenantErrorKind; message: string } };
|
|
62
|
+
|
|
63
|
+
function deny<T>(kind: TenantErrorKind, message: string): TenantResult<T> {
|
|
64
|
+
return { ok: false, error: { kind, message } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* TOFU-binds the operator Membership for a single-tenant deployment (spec
|
|
69
|
+
* D1–D4) — the replacement for the deleted user-agnostic `owner` grant. The
|
|
70
|
+
* Membership is created with a fixed, well-known id
|
|
71
|
+
* (`OPERATOR_MEMBERSHIP_ID`); the `Membership.id` primary-key uniqueness —
|
|
72
|
+
* not a read-then-write "is this the first user?" guard — enforces exactly
|
|
73
|
+
* one owner under a concurrent first-login race. The losing caller's
|
|
74
|
+
* `create` raises `P2002`, swallowed here: that caller is then unbound and
|
|
75
|
+
* denied by the `membership.findUnique` the caller of this function runs
|
|
76
|
+
* next.
|
|
77
|
+
*
|
|
78
|
+
* Idempotent + at-most-once: a caller who already holds the Membership (a
|
|
79
|
+
* repeat call, or the deployment's steady state after the first bind) hits
|
|
80
|
+
* the same `P2002` path and is a no-op. Reachable ONLY from
|
|
81
|
+
* `resolveSingleTenantDefault`, itself gated by the
|
|
82
|
+
* `count === 1 && default-workspace-exists` check — unreachable from any
|
|
83
|
+
* multi-tenant state (`count > 1`).
|
|
84
|
+
*
|
|
85
|
+
* When `AILK_OPERATOR_EMAIL` is set (spec D4 — trimmed + lowercased before
|
|
86
|
+
* comparison), the bind is attempted ONLY for the caller whose `User.email`
|
|
87
|
+
* matches it; every other caller returns without writing and is denied
|
|
88
|
+
* downstream. An empty/whitespace-only `AILK_OPERATOR_EMAIL` normalizes to
|
|
89
|
+
* `""`, which is falsy — treated as unset (pure TOFU), so a blank pin can
|
|
90
|
+
* never "match" a blank `User.email`. When unset, the bind is pure TOFU —
|
|
91
|
+
* first caller wins.
|
|
92
|
+
*/
|
|
93
|
+
async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
|
|
94
|
+
const pinnedEmail = process.env.AILK_OPERATOR_EMAIL?.trim().toLowerCase();
|
|
95
|
+
if (pinnedEmail) {
|
|
96
|
+
const user = await prisma.user.findUnique({
|
|
97
|
+
where: { id: userId },
|
|
98
|
+
select: { email: true },
|
|
99
|
+
});
|
|
100
|
+
if (!user || user.email.trim().toLowerCase() !== pinnedEmail) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await prisma.membership.create({
|
|
106
|
+
data: {
|
|
107
|
+
id: OPERATOR_MEMBERSHIP_ID,
|
|
108
|
+
organizationId: DEFAULT_ORG_ID,
|
|
109
|
+
userId,
|
|
110
|
+
role: "owner",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (!isUniqueViolation(error)) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
// Another caller already holds OPERATOR_MEMBERSHIP_ID (the TOFU race
|
|
118
|
+
// winner, or a prior deployment run) — swallow and fall through.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolves the default single-tenant scope when the deployment is
|
|
124
|
+
* unambiguously single-tenant — the default Workspace (`DEFAULT_WORKSPACE_ID`)
|
|
125
|
+
* exists AND it is the ONLY Workspace (`workspace.count() === 1`), the exact
|
|
126
|
+
* mirror of the Phase-1 provision guard's `count === 0` check (spec D2).
|
|
127
|
+
* Returns `null` (never grants) the instant a second Workspace exists, or the
|
|
128
|
+
* sole Workspace is not the well-known default — the caller falls through to
|
|
129
|
+
* the existing multi-tenant default-deny in that case.
|
|
130
|
+
*
|
|
131
|
+
* Converges on the SAME Membership-based authz the multi-tenant path uses
|
|
132
|
+
* (spec D1 — the fix): attempts to TOFU-bind the caller as operator
|
|
133
|
+
* (`bindOperatorMembership`, a no-op once an operator is already bound or
|
|
134
|
+
* the caller doesn't match the optional env pin), then runs the real
|
|
135
|
+
* `membership.findUnique` check. A caller holding no Membership on the
|
|
136
|
+
* default Org — including every caller after the first once TOFU has bound
|
|
137
|
+
* someone else — resolves `null` here, and `resolveActiveTenant` denies.
|
|
138
|
+
* There is no more user-agnostic `owner` grant.
|
|
139
|
+
*/
|
|
140
|
+
async function resolveSingleTenantDefault(
|
|
141
|
+
userId: Id<"user">,
|
|
142
|
+
): Promise<TenantContext | null> {
|
|
143
|
+
const workspaceCount = await prisma.workspace.count();
|
|
144
|
+
if (workspaceCount !== 1) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const workspace = await prisma.workspace.findUnique({
|
|
148
|
+
where: { id: DEFAULT_WORKSPACE_ID },
|
|
149
|
+
select: { id: true, organizationId: true },
|
|
150
|
+
});
|
|
151
|
+
// The default workspace must hang off the sentinel default Organization
|
|
152
|
+
// (ADR-0031 rule 7): keyed on the reserved-range predicate, not a literal.
|
|
153
|
+
if (!workspace || !isSentinel(workspace.organizationId)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
await bindOperatorMembership(userId);
|
|
157
|
+
const membership = await prisma.membership.findUnique({
|
|
158
|
+
where: {
|
|
159
|
+
organizationId_userId: {
|
|
160
|
+
organizationId: workspace.organizationId,
|
|
161
|
+
userId,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
select: { role: true },
|
|
165
|
+
});
|
|
166
|
+
if (!membership) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
userId,
|
|
171
|
+
organizationId: workspace.organizationId as Id<"organization">,
|
|
172
|
+
workspaceId: workspace.id as Id<"workspace">,
|
|
173
|
+
role: membership.role as MembershipRole,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate the caller's active tenant context. See the guard model above.
|
|
179
|
+
* `sessionTenant` is undefined for bearer-authenticated requests — denied
|
|
180
|
+
* here (default-deny) until agent tokens carry a workspace binding (#3591).
|
|
181
|
+
*/
|
|
182
|
+
export async function resolveActiveTenant(params: {
|
|
183
|
+
userId: Id<"user">;
|
|
184
|
+
sessionTenant: SessionTenant | undefined;
|
|
185
|
+
}): Promise<TenantResult<TenantContext>> {
|
|
186
|
+
const { userId, sessionTenant } = params;
|
|
187
|
+
if (!sessionTenant) {
|
|
188
|
+
return deny("forbidden", "Tenant context requires a cookie session");
|
|
189
|
+
}
|
|
190
|
+
const { activeOrganizationId, activeWorkspaceId } = sessionTenant;
|
|
191
|
+
if (!activeWorkspaceId || !activeOrganizationId) {
|
|
192
|
+
// Single-tenant default resolution (spec D1/D2) — only on the previously
|
|
193
|
+
// unconditional-deny branch, so multi-tenant explicit-binding paths below
|
|
194
|
+
// add zero extra queries. Falls through to the deny below in any
|
|
195
|
+
// multi-tenant state (count > 1, or count === 1 but not the default).
|
|
196
|
+
const singleTenantDefault = await resolveSingleTenantDefault(userId);
|
|
197
|
+
if (singleTenantDefault) {
|
|
198
|
+
return { ok: true, value: singleTenantDefault };
|
|
199
|
+
}
|
|
200
|
+
return deny("forbidden", "No active workspace");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const workspace = await prisma.workspace.findUnique({
|
|
204
|
+
where: { id: activeWorkspaceId },
|
|
205
|
+
select: { id: true, organizationId: true },
|
|
206
|
+
});
|
|
207
|
+
if (!workspace) {
|
|
208
|
+
return deny("forbidden", "Active workspace no longer exists");
|
|
209
|
+
}
|
|
210
|
+
if (workspace.organizationId !== activeOrganizationId) {
|
|
211
|
+
return deny("forbidden", "Stale session tenant context");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const membership = await prisma.membership.findUnique({
|
|
215
|
+
where: {
|
|
216
|
+
organizationId_userId: {
|
|
217
|
+
organizationId: workspace.organizationId,
|
|
218
|
+
userId,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
select: { role: true },
|
|
222
|
+
});
|
|
223
|
+
if (!membership) {
|
|
224
|
+
return deny("forbidden", "Not a member of the active organization");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
value: {
|
|
230
|
+
userId,
|
|
231
|
+
organizationId: workspace.organizationId as Id<"organization">,
|
|
232
|
+
workspaceId: workspace.id as Id<"workspace">,
|
|
233
|
+
role: membership.role as MembershipRole,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The switcher payload: every org the user belongs to, with its workspaces. */
|
|
239
|
+
export interface TenancyListing {
|
|
240
|
+
activeOrganizationId: Id<"organization"> | null;
|
|
241
|
+
activeWorkspaceId: Id<"workspace"> | null;
|
|
242
|
+
organizations: Array<{
|
|
243
|
+
id: Id<"organization">;
|
|
244
|
+
name: string;
|
|
245
|
+
slug: string;
|
|
246
|
+
role: MembershipRole;
|
|
247
|
+
workspaces: Array<{ id: Id<"workspace">; name: string; slug: string }>;
|
|
248
|
+
}>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* List the caller's organizations + workspaces (WorkspaceSwitcher hydration).
|
|
253
|
+
* Membership-scoped by construction: starts from Membership rows, so no org
|
|
254
|
+
* outside the caller's memberships can appear.
|
|
255
|
+
*/
|
|
256
|
+
export async function listTenancy(params: {
|
|
257
|
+
userId: Id<"user">;
|
|
258
|
+
sessionTenant: SessionTenant | undefined;
|
|
259
|
+
}): Promise<TenantResult<TenancyListing>> {
|
|
260
|
+
const memberships = await prisma.membership.findMany({
|
|
261
|
+
where: { userId: params.userId },
|
|
262
|
+
select: {
|
|
263
|
+
role: true,
|
|
264
|
+
organization: {
|
|
265
|
+
select: {
|
|
266
|
+
id: true,
|
|
267
|
+
name: true,
|
|
268
|
+
slug: true,
|
|
269
|
+
workspaces: {
|
|
270
|
+
select: { id: true, name: true, slug: true },
|
|
271
|
+
orderBy: { createdAt: "asc" },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
orderBy: { createdAt: "asc" },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
value: {
|
|
282
|
+
activeOrganizationId: params.sessionTenant?.activeOrganizationId ?? null,
|
|
283
|
+
activeWorkspaceId: params.sessionTenant?.activeWorkspaceId ?? null,
|
|
284
|
+
organizations: memberships.map((m) => ({
|
|
285
|
+
id: m.organization.id as Id<"organization">,
|
|
286
|
+
name: m.organization.name,
|
|
287
|
+
slug: m.organization.slug,
|
|
288
|
+
role: m.role as MembershipRole,
|
|
289
|
+
workspaces: m.organization.workspaces as Array<{
|
|
290
|
+
id: Id<"workspace">;
|
|
291
|
+
name: string;
|
|
292
|
+
slug: string;
|
|
293
|
+
}>,
|
|
294
|
+
})),
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Switch the caller's active workspace (and, implicitly, organization).
|
|
301
|
+
* Grant requires the target workspace to exist AND the caller to hold a
|
|
302
|
+
* Membership in its organization — both failures return `not_found` so a
|
|
303
|
+
* non-member cannot distinguish "exists" from "doesn't" (no enumeration).
|
|
304
|
+
* The update is server-side on the Session row (the thin activeWorkspaceId
|
|
305
|
+
* session extension the apps/workspace auth config declares).
|
|
306
|
+
*/
|
|
307
|
+
export async function activateWorkspace(params: {
|
|
308
|
+
userId: Id<"user">;
|
|
309
|
+
sessionTenant: SessionTenant | undefined;
|
|
310
|
+
workspaceId: Id<"workspace">;
|
|
311
|
+
}): Promise<TenantResult<TenantContext>> {
|
|
312
|
+
const { userId, sessionTenant, workspaceId } = params;
|
|
313
|
+
if (!sessionTenant) {
|
|
314
|
+
return deny("forbidden", "Tenant context requires a cookie session");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const workspace = await prisma.workspace.findUnique({
|
|
318
|
+
where: { id: workspaceId },
|
|
319
|
+
select: { id: true, organizationId: true },
|
|
320
|
+
});
|
|
321
|
+
if (!workspace) {
|
|
322
|
+
return deny("not_found", "Workspace not found");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const membership = await prisma.membership.findUnique({
|
|
326
|
+
where: {
|
|
327
|
+
organizationId_userId: {
|
|
328
|
+
organizationId: workspace.organizationId,
|
|
329
|
+
userId,
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
select: { role: true },
|
|
333
|
+
});
|
|
334
|
+
if (!membership) {
|
|
335
|
+
return deny("not_found", "Workspace not found");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await prisma.session.update({
|
|
339
|
+
where: { id: sessionTenant.sessionRowId },
|
|
340
|
+
data: {
|
|
341
|
+
activeWorkspaceId: workspace.id,
|
|
342
|
+
activeOrganizationId: workspace.organizationId,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
value: {
|
|
349
|
+
userId,
|
|
350
|
+
organizationId: workspace.organizationId as Id<"organization">,
|
|
351
|
+
workspaceId: workspace.id as Id<"workspace">,
|
|
352
|
+
role: membership.role as MembershipRole,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Create a workspace in an organization. Role floor: admin (owner > admin).
|
|
359
|
+
* Non-members get `not_found` (no org enumeration); members below the floor
|
|
360
|
+
* get `forbidden`.
|
|
361
|
+
*/
|
|
362
|
+
export async function createWorkspace(params: {
|
|
363
|
+
userId: Id<"user">;
|
|
364
|
+
organizationId: Id<"organization">;
|
|
365
|
+
name: string;
|
|
366
|
+
}): Promise<
|
|
367
|
+
TenantResult<{
|
|
368
|
+
id: Id<"workspace">;
|
|
369
|
+
name: string;
|
|
370
|
+
slug: string;
|
|
371
|
+
organizationId: Id<"organization">;
|
|
372
|
+
}>
|
|
373
|
+
> {
|
|
374
|
+
const { userId, organizationId, name } = params;
|
|
375
|
+
|
|
376
|
+
const membership = await prisma.membership.findUnique({
|
|
377
|
+
where: { organizationId_userId: { organizationId, userId } },
|
|
378
|
+
select: { role: true },
|
|
379
|
+
});
|
|
380
|
+
if (!membership) {
|
|
381
|
+
return deny("not_found", "Organization not found");
|
|
382
|
+
}
|
|
383
|
+
if (membership.role !== "owner" && membership.role !== "admin") {
|
|
384
|
+
return deny("forbidden", "Creating a workspace requires the admin role");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const slug = slugify(name);
|
|
388
|
+
try {
|
|
389
|
+
const workspace = await prisma.workspace.create({
|
|
390
|
+
data: { organizationId, name, slug },
|
|
391
|
+
select: { id: true, name: true, slug: true, organizationId: true },
|
|
392
|
+
});
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
value: {
|
|
396
|
+
...workspace,
|
|
397
|
+
id: workspace.id as Id<"workspace">,
|
|
398
|
+
organizationId: workspace.organizationId as Id<"organization">,
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (isUniqueViolation(error)) {
|
|
403
|
+
return deny(
|
|
404
|
+
"conflict",
|
|
405
|
+
"A workspace with this name already exists in the organization",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Delete-cascade for a workspace's site config — the service-layer replacement
|
|
414
|
+
* for the dropped cross-namespace FK (ADR-0009 D3 / #3634 D3). Removes the
|
|
415
|
+
* workspace's `WorkspaceTheme` / `WorkspaceSiteIdentity` / `WorkspaceSEO` rows,
|
|
416
|
+
* each scoped by `workspaceId` (the `@unique` key), so it can never touch
|
|
417
|
+
* another workspace's rows.
|
|
418
|
+
*
|
|
419
|
+
* OPTION 1 (Sam-disposed, #3634): this is a HELPER ONLY — no DELETE route,
|
|
420
|
+
* caller, or invocation exists in this phase. The destructive owner-gated
|
|
421
|
+
* entry-point that will call it is a separate, parked, Sam-reviewed issue. It
|
|
422
|
+
* takes a bare `workspaceId` (not a `TenantContext`) because that future caller
|
|
423
|
+
* has already authorized the delete; scope-safety is the caller's contract.
|
|
424
|
+
*
|
|
425
|
+
* Lives here (a `tenant-scope`-excluded file) so the raw `prisma.workspace*`
|
|
426
|
+
* calls pass the architecture lint.
|
|
427
|
+
*/
|
|
428
|
+
export async function deleteWorkspaceConfig(workspaceId: string): Promise<void> {
|
|
429
|
+
await prisma.workspaceTheme.deleteMany({ where: { workspaceId } });
|
|
430
|
+
await prisma.workspaceSiteIdentity.deleteMany({ where: { workspaceId } });
|
|
431
|
+
await prisma.workspaceSEO.deleteMany({ where: { workspaceId } });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function slugify(name: string): string {
|
|
435
|
+
return (
|
|
436
|
+
name
|
|
437
|
+
.toLowerCase()
|
|
438
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
439
|
+
.replace(/^-+|-+$/g, "")
|
|
440
|
+
.slice(0, 48) || "workspace"
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
445
|
+
return (
|
|
446
|
+
typeof error === "object" &&
|
|
447
|
+
error !== null &&
|
|
448
|
+
"code" in error &&
|
|
449
|
+
(error as { code?: unknown }).code === "P2002"
|
|
450
|
+
);
|
|
451
|
+
}
|