create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- 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/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -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/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,451 +0,0 @@
|
|
|
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
|
-
}
|