create-ailk 0.3.0 → 0.5.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.
Files changed (155) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +48 -10
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +88 -1
  6. package/dist/lib/extract-module-bundle.js +375 -80
  7. package/dist/lib/fetch-guard.d.ts +28 -0
  8. package/dist/lib/fetch-guard.js +101 -0
  9. package/dist/lib/fetch-module.js +4 -0
  10. package/dist/lib/fetch-template.d.ts +20 -0
  11. package/dist/lib/fetch-template.js +133 -0
  12. package/dist/lib/module-license-gate.d.ts +8 -0
  13. package/dist/lib/module-license-gate.js +12 -4
  14. package/dist/module-architecture.d.ts +116 -7
  15. package/dist/module-architecture.js +465 -94
  16. package/dist/parse-args.d.ts +8 -1
  17. package/dist/parse-args.js +49 -2
  18. package/dist/programmatic.d.ts +36 -1
  19. package/dist/programmatic.js +216 -5
  20. package/dist/site-chrome.d.ts +80 -0
  21. package/dist/site-chrome.js +135 -0
  22. package/dist/surfaces.d.ts +21 -6
  23. package/dist/surfaces.js +140 -23
  24. package/dist/template-manifest.d.ts +144 -0
  25. package/dist/template-manifest.js +337 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +45 -0
  28. package/package.json +10 -2
  29. package/site-templates/_base/ailk-template.json +6 -0
  30. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  31. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/_base/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  35. package/site-templates/marketing-site/ailk-template.json +11 -0
  36. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  37. package/site-templates/marketing-site/provenance.json +4 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  39. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  40. package/site-templates/marketing-site-free/ailk-template.json +9 -0
  41. package/site-templates/marketing-site-free/provenance.json +4 -0
  42. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  43. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  44. package/site-templates/minimalism-site/ailk-template.json +21 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  49. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  50. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  51. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  53. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  54. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  55. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  56. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  57. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  58. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  60. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  65. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  68. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  69. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  70. package/site-templates/minimalism-site/provenance.json +4 -0
  71. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  72. package/site-templates/saas-product/ailk-template.json +16 -0
  73. package/site-templates/saas-product/provenance.json +10 -0
  74. package/templates/.claude/skills/README.md +16 -25
  75. package/templates/.env.example +32 -7
  76. package/templates/.github/workflows/ci.yml +48 -0
  77. package/templates/README.md +3 -1
  78. package/templates/apps/api/CLAUDE.md +4 -1
  79. package/templates/apps/api/eslint.config.mjs +69 -0
  80. package/templates/apps/api/package.json +6 -2
  81. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  82. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  83. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +173 -0
  84. package/templates/apps/api/src/config/modules.ts +1 -1
  85. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  86. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  87. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  88. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  89. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  90. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  91. package/templates/apps/api/src/server.ts +122 -3
  92. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  93. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  94. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  95. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  96. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  97. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  98. package/templates/apps/mcp/CLAUDE.md +1 -1
  99. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  100. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  101. package/templates/apps/mcp/eslint.config.mjs +69 -0
  102. package/templates/apps/mcp/package.json +6 -2
  103. package/templates/apps/mcp/src/server.ts +2 -2
  104. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  105. package/templates/apps/mcp/src/tools/index.ts +2 -2
  106. package/templates/apps/web/.env.example +3 -3
  107. package/templates/apps/web/CLAUDE.md +2 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  109. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  111. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  112. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  113. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  115. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  119. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  120. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  121. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  122. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  123. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  124. package/templates/apps/web/components/google-analytics.tsx +18 -1
  125. package/templates/apps/web/eslint.config.mjs +69 -0
  126. package/templates/apps/web/jest.config.cjs +12 -15
  127. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  128. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  129. package/templates/apps/web/lib/nav-config.ts +27 -7
  130. package/templates/apps/web/lib/server-api.ts +29 -13
  131. package/templates/apps/web/lib/site-chrome.ts +29 -0
  132. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  133. package/templates/apps/web/messages/en.json +86 -1
  134. package/templates/apps/web/next.config.mjs +20 -0
  135. package/templates/apps/web/package.json +6 -2
  136. package/templates/apps/web/sentry.client.config.ts +131 -0
  137. package/templates/apps/web/sentry.edge.config.ts +72 -0
  138. package/templates/apps/web/sentry.server.config.ts +72 -0
  139. package/templates/apps/web/src/lib/routes.ts +13 -18
  140. package/templates/brand_assets/README.md +24 -0
  141. package/templates/content/en/AboutPage/about.mdx +160 -15
  142. package/templates/content/en/ContactPage/contact.mdx +90 -13
  143. package/templates/content/en/FAQPage/faq.mdx +62 -22
  144. package/templates/content/en/HomePage/home.mdx +244 -20
  145. package/templates/content/en/LandingPage/blog.mdx +83 -0
  146. package/templates/content/en/LandingPage/features.mdx +103 -0
  147. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  148. package/templates/database/CHANGELOG.md +21 -0
  149. package/templates/database/__tests__/seed.test.ts +1 -1
  150. package/templates/database/eslint.config.mjs +69 -0
  151. package/templates/database/package.json +7 -3
  152. package/templates/database/seed.ts +1 -1
  153. package/templates/package.json +1 -14
  154. package/versions.json +52 -0
  155. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -50,16 +50,41 @@ CONTENT_BACKEND=mdx
50
50
  # BETTER_AUTH_URL=http://localhost:3000
51
51
  # BETTER_AUTH_SECRET=
52
52
 
53
- # ── Single-tenant operator pin (multi-tenant module, apps/api) ───────────────
53
+ # ── Single-tenant operator pin (apps/api) ────────────────────────────────────
54
+ # REQUIRED on a build WITHOUT the multi-tenant module if you want the
55
+ # submissions review dashboard. OPTIONAL with the module.
56
+ #
54
57
  # On a single-tenant deployment (the default Workspace exists and is the ONLY
55
58
  # Workspace) the FIRST authenticated user to resolve a tenant context is
56
59
  # bound as the sole owner (TOFU — trust on first use); every subsequent
57
- # caller is denied. AILK_OPERATOR_EMAIL is OPTIONAL and pins that binding to
58
- # one email address instead of "whoever logs in first" — set it when you
59
- # want to guarantee only a specific operator account can ever become owner.
60
- # Case-insensitive, trimmed. Leave unset for pure TOFU.
60
+ # caller is denied. AILK_OPERATOR_EMAIL pins that binding to one email
61
+ # address instead of "whoever logs in first". Case-insensitive, trimmed.
62
+ #
63
+ # WITHOUT the multi-tenant module it is MANDATORY and unset fails CLOSED:
64
+ # the review routes (/v1/waitlist-experiments/*, the tenant-scoped
65
+ # /v1/project-listings/*) answer 403 to everyone, including you. That is
66
+ # deliberate. Those routes can widen to an unfiltered, every-site read for an
67
+ # owner of the default Organization, so an unpinned deployment must not seat
68
+ # whoever authenticates first — on a site with public sign-ups that is not
69
+ # necessarily you. The pinned account's email must also be VERIFIED.
70
+ #
71
+ # If your own dashboard returns 403, this variable is the first thing to
72
+ # check. With the multi-tenant module present, resolution comes from the
73
+ # session's active workspace and this stays optional, exactly as before.
61
74
  # AILK_OPERATOR_EMAIL=
62
75
  #
76
+ # ── Site id (apps/api, apps/web) ─────────────────────────────────────────────
77
+ # The scope captured rows are stamped with. Lead, WaitlistExperiment and
78
+ # ProjectListing carry `siteId`, which defaults to the literal "default"
79
+ # (apps/api/src/lib/site.ts), while tenancy scopes reads by WORKSPACE id.
80
+ # A single-tenant deployment should set this to the default workspace
81
+ # sentinel so the two agree:
82
+ # AILK_SITE_ID=wksp_00000000-0000-7000-8000-000000000001
83
+ #
84
+ # Leaving them disagreeing is why a correctly pinned owner can still open the
85
+ # review dashboard and see nothing: the rows exist, but under a different
86
+ # scope key than the one their session resolves.
87
+ #
63
88
  # Belt-and-suspenders: the owner-pin denies any non-operator account at
64
89
  # tenant resolution regardless, but for a single-tenant deployment you should
65
90
  # also disable open sign-up / configure an OAuth allowlist on your identity
@@ -291,9 +316,9 @@ CONTENT_BACKEND=mdx
291
316
  # SCHEDULING_PUBLIC_BASE_URL=https://cal.com
292
317
  # SCHEDULING_PROVIDER=cal
293
318
  #
294
- # Which event the /schedule page books, as "<owner>/<event-slug>" (the path of
319
+ # Which event the /meet page books, as "<owner>/<event-slug>" (the path of
295
320
  # the provider's own public booking page). Read server-side by apps/web; unset
296
- # means the /schedule route 404s, exactly as if the scheduling module were not
321
+ # means the /meet route 404s, exactly as if the scheduling module were not
297
322
  # installed. Deliberately NOT a NEXT_PUBLIC_ variable — the whole scheduling
298
323
  # config surface is server-read.
299
324
  # SCHEDULING_CAL_LINK=owner/event-slug
@@ -0,0 +1,48 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+
6
+ jobs:
7
+ ci:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+
12
+ - uses: pnpm/action-setup@v4
13
+
14
+ # A fresh scaffold ships no pnpm-lock.yaml — it is generated by the
15
+ # customer's own first `pnpm install`. actions/setup-node's built-in
16
+ # pnpm cache globs for cache-dependency-path and errors the step
17
+ # outright when nothing matches, so a PR opened before that first
18
+ # install would fail here before `pnpm install` below even runs. This
19
+ # placeholder only stands in for the glob; `pnpm install` still
20
+ # generates (or refreshes) the real lockfile every run.
21
+ - run: test -f pnpm-lock.yaml || touch pnpm-lock.yaml
22
+
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: 20
26
+ cache: pnpm
27
+ cache-dependency-path: pnpm-lock.yaml
28
+
29
+ # Not --frozen-lockfile: the scaffold ships no pnpm-lock.yaml (it is
30
+ # generated fresh by the customer's own first `pnpm install`, and the
31
+ # placeholder step above is a cache-key stand-in, not a real lockfile),
32
+ # so a --frozen install would fail outright on any PR opened before a
33
+ # real lockfile has been committed.
34
+ - run: pnpm install
35
+
36
+ # Prisma's generated client (database/generated/) is a build artifact,
37
+ # not part of the scaffold — type-check/build/test all resolve
38
+ # `@working-theory/database`'s generated types, and `prisma generate` needs
39
+ # no DATABASE_URL (it reads only the schema file, no live DB).
40
+ - run: pnpm db:generate
41
+
42
+ - run: pnpm type-check
43
+
44
+ - run: pnpm lint
45
+
46
+ - run: pnpm test
47
+
48
+ - run: pnpm build
@@ -103,7 +103,9 @@ If you need to override a section component, swap its registration in the sectio
103
103
 
104
104
  ## License & contributing
105
105
 
106
- Your project — `{{PROJECT_NAME}}`, owned by {{COMPANY_NAME}} ({{COMPANY_DOMAIN}}) — is yours. Pick whatever license suits you.
106
+ This is your code — `{{PROJECT_NAME}}`, owned by {{COMPANY_NAME}} ({{COMPANY_DOMAIN}}). The root `package.json`'s `license` field ships set to `UNLICENSED` as a neutral placeholder; it's yours to change to whatever license suits your project (or to remove entirely if the repo is private).
107
+
108
+ The `LICENSE` files under `apps/*/` and `database/` are the exception — they stay **Apache 2.0** because those directories are snapshots of `@working-theory/*` packages' own source, not code you authored.
107
109
 
108
110
  The AI Launch Kit framework upstream is **Apache 2.0** — see <https://github.com/working-theory-labs/ai-launch-kit/blob/main/LICENSE>. Contributions are welcome at the upstream repo; start with the [contributing guide](https://github.com/working-theory-labs/ai-launch-kit/blob/main/CONTRIBUTING.md).
109
111
 
@@ -37,6 +37,7 @@ Every endpoint here has a 1:1 MCP tool in `apps/mcp`. When you add, change, or r
37
37
  Routes are versioned under `/v1/*`:
38
38
 
39
39
  - `/v1/leads/{submit,subscribe,callback}` — lead capture, newsletter subscription, callback request
40
+ - `/v1/leads` (GET, `?cursor=&limit=`) — the leads INBOX read (#5678): the caller's OWN sites' leads, newest first, cursor-paginated (`limit` defaults to 50, max 200; `(createdAt desc, id desc)`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a 400, never a silent page 1 — the API keeps that contract, and the web inbox deliberately softens it on its own side by re-reading page one when a URL-supplied cursor 400s, which is a rendering choice and not a change to this response). Five fields per row and nothing more — `id`, `name` (lifted from `payload.name` when the site's form captured one), `email`, `source`, `createdAt`; `payload`'s other keys, `siteId`, `promotedUserId` and `promotedAt` are never selected (`src/services/leads-inbox.ts` is the only read path). Registered in `authScope` only (auth, no tenant guard) and — alone in that scope — **unconditionally**, with no `isModuleEnabled()` gate: lead capture is a never-gate surface at every level, so reading a lead back cannot be gated either. Identity is `request.userId`; there is no site, org or workspace parameter, so the scope cannot be widened from the wire. It is resolved from the caller's own memberships by `src/services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace resolution in this app, shared with `lead-promotion.ts` rather than written a second time — and every read filters `siteId ∈ workspaceIds`. Role floor `owner|admin` (contact PII), the same floor and the same reason as `/v1/waitlist-experiments/*`; the scope is the UNION of every workspace the caller owns or administers, not one active tenant, because this route registers outside the tenant scope. **Two deployment preconditions decide whether it returns anything, and both fail EMPTY rather than wide:** a build with no `multi-tenant` module resolves no workspaces (a deliberate scope limit since #5677, not an absence of anything to resolve), and the `siteId` the leads were WRITTEN under must equal a workspace id. That `siteId` has TWO sources, and `currentSiteId()` is not the one on the primary path: the three POST capture routes read it from the REQUEST BODY (`routes/leads/index.ts`), which the capture sections declare in content and default to `"default"` (`ContactForm`/`Newsletter`/`StepFlow`, `ext.siteId ?? "default"`), while the recommender's capture path (`routes/flows/recommender.ts`) is the one that uses `currentSiteId()` (`lib/site.ts`, `AILK_SITE_ID ?? "default"`). A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` matches only when the content-declared value — and, for recommender leads, `AILK_SITE_ID` — is set to it (the same convention `waitlist-dashboard.ts` already relies on). The rendered `name` is lifted from `payload.name` and truncated at 200 characters: the write side bounds `payload` at 64 keys and no per-value length, and `POST /v1/leads/submit` is anonymous with no ownership check on `siteId`. No MCP twin — identity-bound (#3591 exception, the same shape as `/v1/workspaces/*` and `/v1/billing/*`).
40
41
  - `/v1/flows/:slug` (GET), `/v1/flows/:slug/steps` (POST) — serve a flow config + submit a step's answers; server-side per-step partial-state persistence (#3956). Module: `signal-collectors` (D-48, #5366) — registers only when the module is enabled; MCP twins `get_flow`/`submit_flow_step` carry the same `module` tag (D7).
41
42
  - `/v1/flows/:slug/aggregate` (GET) — anonymous, site-scoped per-question response counts across all completed respondents for the results reveal (#4771): respondents are completed `FlowSession` rows (not `Lead` rows — a recommending-mode respondent has no Lead until the email moment), question metadata + the chart flag come from the `WaitlistExperiment` register, and only `resultsReveal`-flagged `single`/`multi` questions are returned (`src/services/flow-aggregate.ts`). No individual answer, email, name, session id, or score in the response; no auth. Module: `signal-collectors`. No MCP twin — recorded exception (an agent walking a flow already gets everything it needs from `submit_flow_step`'s own response; "how everyone else answered" changes none of it).
42
43
  - `/v1/deliverables/:grantId` (GET) — the capability-token download route for gated PDFs and reports, including client deliverables (#3961 D5); the token itself is the sole gate, no auth preHandler applies (`src/routes/deliverables/index.ts`). Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound capability-link download (#3591 exception, the same shape as `/v1/waitlist-signups/me`).
@@ -143,7 +144,9 @@ sweep's own DB constraint (never a caller-side lock).
143
144
  - Don't put session state in `apps/api`. It's stateless.
144
145
  - Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
145
146
 
146
- Last updated: 2026-09-14 — doc-sync: signal-collectors module extraction (D-48, #5366) — added the `module: signal-collectors` note to the flows/waitlist-signups-me/project-listings-me endpoints, added the previously-undocumented `/v1/deliverables/:grantId`, `/v1/project-listings/{start,submit,:id,public}` and `/v1/slack/actions` rows — verified against `apps/api/src/server.ts` and each route's own header.
147
+ Last updated: 2026-09-16 — doc-sync: added `GET /v1/leads` (#5678), the owner-scoped leads inbox read, to Endpoint conventions, recording its unconditional registration, its five-field row, its `resolveOwnerWorkspaces` scoping and its no-MCP-twin parity decision — verified against `apps/api/src/routes/leads/list.ts`, `apps/api/src/services/leads-inbox.ts`, `apps/api/src/services/owner-workspaces.ts` and `apps/api/src/server.ts`.
148
+
149
+ Prior: 2026-09-14 — doc-sync: signal-collectors module extraction (D-48, #5366) — added the `module: signal-collectors` note to the flows/waitlist-signups-me/project-listings-me endpoints, added the previously-undocumented `/v1/deliverables/:grantId`, `/v1/project-listings/{start,submit,:id,public}` and `/v1/slack/actions` rows — verified against `apps/api/src/server.ts` and each route's own header.
147
150
 
148
151
  Prior: 2026-09-12 — doc-sync: added the `## Deploy path` section and the matching `## What NOT to do` line (#5334), recording that bundling `apps/api` is not a supported deploy path and naming the three gates — verified against `apps/api/vercel.json`, `apps/api/src/lib/slice-load.ts` and `scripts/check-bundled-build-diagnoses.ts`.
149
152
 
@@ -0,0 +1,69 @@
1
+ // Self-contained ESLint flat config for this scaffolded package.
2
+ //
3
+ // It deliberately does NOT depend on @working-theory/eslint-config — that
4
+ // package is internal to the AI Launch Kit monorepo and is not published, so
5
+ // it cannot be a dependency of a scaffolded starter. Everything below comes
6
+ // from public npm packages already pinned as devDependencies (see
7
+ // package.json): @eslint/js, globals and typescript-eslint.
8
+ //
9
+ // Source file name (#5556): this file is checked in as `_eslint.config.mjs`,
10
+ // not `eslint.config.mjs` — build-templates.mjs renames it at snapshot time
11
+ // (renameScaffoldLintConfigs()). A file literally named `eslint.config.mjs`
12
+ // anywhere under this monorepo (even one this repo's own root eslint.config.ts
13
+ // lists in its `ignores`) is auto-discovered by ESLint's flat-config nested
14
+ // resolution as a competing config root, which breaks the monorepo's own
15
+ // lint runs (verified live: typescript-eslint's "multiple candidate
16
+ // TSConfigRootDirs" parser error across unrelated packages/create-ailk
17
+ // files). The underscore prefix keeps it invisible to that discovery while
18
+ // it lives in this checkout; only the snapshot in templates/ — which ships
19
+ // to the customer, not this repo — gets the real filename.
20
+ import js from "@eslint/js";
21
+ import globals from "globals";
22
+ import tseslint from "typescript-eslint";
23
+
24
+ export default tseslint.config(
25
+ {
26
+ ignores: [
27
+ "**/.next/**",
28
+ "**/dist/**",
29
+ "**/generated/**",
30
+ "**/node_modules/**",
31
+ ],
32
+ },
33
+ js.configs.recommended,
34
+ ...tseslint.configs.recommended,
35
+ {
36
+ languageOptions: {
37
+ globals: {
38
+ ...globals.node,
39
+ ...globals.browser,
40
+ ...globals.jest,
41
+ },
42
+ // Explicit, self-contained root — this scaffold config is never meant
43
+ // to combine with any sibling scaffold package's config (each ships
44
+ // standalone), and leaving it unset makes typescript-eslint's
45
+ // candidate-root disambiguation ambiguous the moment more than one
46
+ // scaffold package's config resolves in the same ESLint process (e.g.
47
+ // this monorepo's own `templates/` build, where all four exist
48
+ // side by side; a real customer scaffold never has that problem,
49
+ // since it only ever ships and lints ONE of these four at a time).
50
+ parserOptions: {
51
+ tsconfigRootDir: import.meta.dirname,
52
+ },
53
+ },
54
+ rules: {
55
+ // Downgraded rather than off, so both stay visible without failing a
56
+ // fresh scaffold's lint:
57
+ // - unused-vars: most of the source follows the underscore-prefix
58
+ // convention for intentionally-unused bindings, which this rule's
59
+ // default options don't recognize; a handful of test fixtures also
60
+ // carry genuinely-unused locals this minimal config doesn't try to
61
+ // triage file-by-file.
62
+ // - require-imports: CommonJS require() is the correct form in Jest
63
+ // config/mock files and in a few test-only dynamic re-require
64
+ // patterns.
65
+ "@typescript-eslint/no-unused-vars": "warn",
66
+ "@typescript-eslint/no-require-imports": "warn",
67
+ },
68
+ },
69
+ );
@@ -20,7 +20,7 @@
20
20
  "smoke:dev": "node -e \"const{spawn}=require('child_process');const http=require('http');const PORT=process.env.PORT||'3002';const SLUG=process.env.SMOKE_FLOW_SLUG||'smoke-check';const child=spawn('pnpm',['exec','tsx','watch','src/cli.ts'],{env:process.env,stdio:'ignore'});let done=false;function finish(code){if(done)return;done=true;try{child.kill();}catch(e){}process.exit(code);}function poll(attempt){if(attempt>40){console.error('smoke:dev - timeout waiting for server');finish(1);return;}const req=http.get({host:'127.0.0.1',port:PORT,path:'/v1/flows/'+SLUG},(res)=>{console.log('smoke:dev - GET /v1/flows/'+SLUG+' -> HTTP '+res.statusCode);res.resume();finish(0);});req.on('error',()=>{setTimeout(()=>poll(attempt+1),500);});}child.on('exit',(code)=>{if(!done){console.error('smoke:dev - child exited early with code '+code);finish(1);}});setTimeout(()=>poll(0),500);\"",
21
21
  "smoke:build": "node -e \"const{spawn}=require('child_process');const http=require('http');const PORT=process.env.PORT||'3002';const SLUG=process.env.SMOKE_FLOW_SLUG||'smoke-check';const child=spawn('node',['dist/cli.js'],{env:process.env,stdio:'ignore'});let done=false;function finish(code){if(done)return;done=true;try{child.kill();}catch(e){}process.exit(code);}function poll(attempt){if(attempt>40){console.error('smoke:build - timeout waiting for server');finish(1);return;}const req=http.get({host:'127.0.0.1',port:PORT,path:'/v1/flows/'+SLUG},(res)=>{console.log('smoke:build - GET /v1/flows/'+SLUG+' -> HTTP '+res.statusCode);res.resume();finish(0);});req.on('error',()=>{setTimeout(()=>poll(attempt+1),500);});}child.on('exit',(code)=>{if(!done){console.error('smoke:build - child exited early with code '+code);finish(1);}});setTimeout(()=>poll(0),500);\"",
22
22
  "smoke": "pnpm run smoke:dev && pnpm run smoke:build",
23
- "lint": "tsx ../../scripts/lint-package.ts",
23
+ "lint": "eslint .",
24
24
  "test": "jest",
25
25
  "test:watch": "jest --watch",
26
26
  "test:coverage": "jest --coverage",
@@ -64,6 +64,10 @@
64
64
  "prisma": "^7.8.0",
65
65
  "ts-jest": "^29.4.11",
66
66
  "tsx": "^4.22.4",
67
- "typescript": "^6.0.3"
67
+ "typescript": "^6.0.3",
68
+ "eslint": "^10.6.0",
69
+ "@eslint/js": "^10.0.1",
70
+ "globals": "^17.7.0",
71
+ "typescript-eslint": "^8.62.1"
68
72
  }
69
73
  }
@@ -59,11 +59,15 @@ async function withEnabledModules<T>(
59
59
  // caught by the "still registered" assertions below.
60
60
  const MODULE_ROUTES: Record<string, { method: 'GET' | 'POST'; url: string }[]> =
61
61
  {
62
+ // /v1/waitlist-experiments and /v1/project-listings are deliberately NOT
63
+ // listed here since #5677: the review surface registers under EITHER
64
+ // multi-tenant OR signal-collectors, so excluding multi-tenant alone
65
+ // leaves them registered by design — the same shape the workspace-settings
66
+ // routes have. Their matrix is `review-surface-registration.test.ts`,
67
+ // beside `workspace-module-registration.test.ts`.
62
68
  'multi-tenant': [
63
69
  { method: 'GET', url: '/v1/workspaces' },
64
70
  { method: 'GET', url: '/v1/billing' },
65
- { method: 'GET', url: '/v1/waitlist-experiments' },
66
- { method: 'GET', url: '/v1/project-listings' },
67
71
  ],
68
72
  scheduling: [{ method: 'GET', url: '/v1/schedule/availability' }],
69
73
  'content-write': [{ method: 'POST', url: '/v1/content/BlogPosting' }],
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @file review-surface-registration.test.ts
3
+ * @description The `multi-tenant || signal-collectors` registration matrix for
4
+ * the review surface (#5677).
5
+ *
6
+ * This is the TWO-SIDED check the unit turns on. `signal-collectors` (Pro) is
7
+ * the module that sells the instruments — flows, deliverables, project
8
+ * listings, waitlist experiments — so a Pro customer could collect submissions
9
+ * and not read them, because the review routes registered only inside the
10
+ * multi-tenant scope. Widening the gate has to make the Pro side work WITHOUT
11
+ * moving the Pro Plus side, and only exercising both builds shows that.
12
+ *
13
+ * It cannot fold into `module-exclusion.test.ts`: that suite's shape is
14
+ * "excluding module X 404s every route X owns", and these routes register
15
+ * under EITHER module, so excluding one while the other is on leaves them
16
+ * registered by design — the same reason `workspace-module-registration.test.ts`
17
+ * is its own file, whose structure this mirrors.
18
+ *
19
+ * "Resolves" is measured as "not Fastify's own 404". An unauthenticated
20
+ * request to a registered tenant route answers 401, never 404, so the two
21
+ * states are unambiguous.
22
+ *
23
+ * Mirrors `src/__tests__/server.test.ts`'s external-boundary mocks (CORS,
24
+ * rate-limit, email) — see that file's header for why each is mocked.
25
+ */
26
+
27
+ jest.mock('@fastify/cors', () => {
28
+ return jest.fn().mockResolvedValue(undefined);
29
+ });
30
+
31
+ jest.mock('@fastify/rate-limit', () => {
32
+ return jest.fn().mockResolvedValue(undefined);
33
+ });
34
+
35
+ jest.mock('@working-theory/email', () => ({
36
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
37
+ }));
38
+
39
+ import { build } from '../server';
40
+
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
42
+ type App = any;
43
+
44
+ const EXPERIMENT = 'wlx_00000000-0000-7000-8000-0000000000aa';
45
+
46
+ /** The review surface — registers under EITHER module since #5677. */
47
+ const REVIEW_ROUTES = [
48
+ { method: 'GET' as const, url: '/v1/waitlist-experiments' },
49
+ { method: 'GET' as const, url: `/v1/waitlist-experiments/${EXPERIMENT}` },
50
+ {
51
+ method: 'GET' as const,
52
+ url: `/v1/waitlist-experiments/${EXPERIMENT}/signups`,
53
+ },
54
+ { method: 'GET' as const, url: '/v1/project-listings' },
55
+ ];
56
+
57
+ /**
58
+ * Organization/Workspace ADMINISTRATION — deliberately left alone by #5677,
59
+ * because it is not a site's own submissions. These stay multi-tenant-only.
60
+ */
61
+ const MULTI_TENANT_ONLY_ROUTES = [
62
+ { method: 'GET' as const, url: '/v1/workspaces' },
63
+ { method: 'GET' as const, url: '/v1/billing' },
64
+ ];
65
+
66
+ async function withEnabledModules<T>(
67
+ enabled: string,
68
+ fn: () => Promise<T>,
69
+ ): Promise<T> {
70
+ const prior = process.env.AILK_ENABLED_MODULES;
71
+ process.env.AILK_ENABLED_MODULES = enabled;
72
+ try {
73
+ return await fn();
74
+ } finally {
75
+ if (prior === undefined) {
76
+ delete process.env.AILK_ENABLED_MODULES;
77
+ } else {
78
+ process.env.AILK_ENABLED_MODULES = prior;
79
+ }
80
+ }
81
+ }
82
+
83
+ /** Every (method, path) pair Fastify's router holds, one per line. */
84
+ function routeLines(app: App): string[] {
85
+ return (app.printRoutes({ commonPrefix: false }) as string)
86
+ .split('\n')
87
+ .map((line: string) => line.trim())
88
+ .filter(Boolean);
89
+ }
90
+
91
+ describe('review-surface registration matrix (#5677)', () => {
92
+ let app: App;
93
+
94
+ afterEach(async () => {
95
+ if (app) {
96
+ await app.close();
97
+ app = undefined;
98
+ }
99
+ delete process.env.AILK_ENABLED_MODULES;
100
+ });
101
+
102
+ it("with 'signal-collectors' alone (a Pro build), every review route resolves behind the base tenant guard", async () => {
103
+ app = await withEnabledModules('signal-collectors', () => build());
104
+
105
+ for (const { method, url } of REVIEW_ROUTES) {
106
+ const res = await app.inject({ method, url });
107
+ expect({ url, status: res.statusCode }).toEqual({ url, status: 401 });
108
+ }
109
+ });
110
+
111
+ it("with 'signal-collectors' alone, Organization/Workspace administration stays unregistered", async () => {
112
+ app = await withEnabledModules('signal-collectors', () => build());
113
+
114
+ for (const { method, url } of MULTI_TENANT_ONLY_ROUTES) {
115
+ const res = await app.inject({ method, url });
116
+ expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
117
+ }
118
+ });
119
+
120
+ it("with 'multi-tenant' alone (a Pro Plus build), every review route resolves exactly as before — the regression floor", async () => {
121
+ app = await withEnabledModules('multi-tenant', () => build());
122
+
123
+ for (const { method, url } of [
124
+ ...REVIEW_ROUTES,
125
+ ...MULTI_TENANT_ONLY_ROUTES,
126
+ ]) {
127
+ const res = await app.inject({ method, url });
128
+ expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
129
+ }
130
+ });
131
+
132
+ it('with BOTH enabled, each review route registers exactly once', async () => {
133
+ // build() itself is the first assertion: Fastify throws
134
+ // FST_ERR_DUPLICATED_ROUTE at registration time, so a double-registration
135
+ // never reaches the router tree to be counted.
136
+ app = await withEnabledModules('multi-tenant,signal-collectors', () =>
137
+ build(),
138
+ );
139
+
140
+ const lines = routeLines(app);
141
+ const experimentRoutes = lines.filter((line) =>
142
+ line.includes('waitlist-experiments'),
143
+ );
144
+ expect(experimentRoutes.length).toBeGreaterThan(0);
145
+ expect(new Set(experimentRoutes).size).toBe(experimentRoutes.length);
146
+
147
+ for (const { method, url } of [
148
+ ...REVIEW_ROUTES,
149
+ ...MULTI_TENANT_ONLY_ROUTES,
150
+ ]) {
151
+ const res = await app.inject({ method, url });
152
+ expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
153
+ }
154
+ });
155
+
156
+ it('with NEITHER enabled (a Free build), no review route registers — Free is unaffected', async () => {
157
+ app = await withEnabledModules('', () => build());
158
+
159
+ for (const { method, url } of [
160
+ ...REVIEW_ROUTES,
161
+ ...MULTI_TENANT_ONLY_ROUTES,
162
+ ]) {
163
+ const res = await app.inject({ method, url });
164
+ expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
165
+ }
166
+ });
167
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @file workspace-module-registration.test.ts
3
+ * @description The `workspace || multi-tenant` registration matrix (#5371 D2).
4
+ *
5
+ * `workspace` cannot be folded into the sibling `module-exclusion.test.ts`:
6
+ * that suite's shape is "excluding module X 404s every route X owns", and the
7
+ * three routes here register under EITHER module, so excluding `workspace`
8
+ * while `multi-tenant` is on leaves them registered by design. That is the
9
+ * whole point of D2's gate, so it gets its own matrix.
10
+ *
11
+ * The three cases §5.1 names:
12
+ *
13
+ * 1. workspace only — the three workspace-settings routes resolve for a
14
+ * single-tenant session; billing / workspace-session
15
+ * / org routes stay unregistered.
16
+ * 2. multi-tenant only — everything resolves exactly as it did before this
17
+ * change (the regression floor).
18
+ * 3. both enabled — each of workspaceConfigRoutes /
19
+ * workspaceGeneralRoutes / workspaceMembersRoutes
20
+ * registers exactly ONCE; Fastify throws on a
21
+ * duplicate route at build() time, so a successful
22
+ * build IS the assertion, backed by an explicit
23
+ * count over the router tree.
24
+ *
25
+ * "Resolves" is measured as "not Fastify's own 404". An unauthenticated
26
+ * request to a registered tenant route answers 401, never 404, so the two
27
+ * states are unambiguous — the same signal `module-exclusion.test.ts` uses.
28
+ *
29
+ * Mirrors `src/__tests__/server.test.ts`'s external-boundary mocks (CORS,
30
+ * rate-limit, email) — see that file's header for why each is mocked.
31
+ */
32
+
33
+ jest.mock('@fastify/cors', () => {
34
+ return jest.fn().mockResolvedValue(undefined);
35
+ });
36
+
37
+ jest.mock('@fastify/rate-limit', () => {
38
+ return jest.fn().mockResolvedValue(undefined);
39
+ });
40
+
41
+ jest.mock('@working-theory/email', () => ({
42
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
43
+ }));
44
+
45
+ import { build } from '../server';
46
+
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
48
+ type App = any;
49
+
50
+ const WS = 'wksp_00000000-0000-7000-8000-0000000000aa';
51
+
52
+ /** The routes the widened gate must serve under EITHER module. */
53
+ const SHARED_TENANT_ROUTES = [
54
+ { method: 'GET' as const, url: `/v1/workspaces/${WS}/theme` },
55
+ { method: 'GET' as const, url: `/v1/workspaces/${WS}/identity` },
56
+ { method: 'GET' as const, url: `/v1/workspaces/${WS}/seo` },
57
+ { method: 'GET' as const, url: `/v1/workspaces/${WS}/general` },
58
+ { method: 'GET' as const, url: `/v1/workspaces/${WS}/members` },
59
+ ];
60
+
61
+ /**
62
+ * Routes that must NOT register under `workspace` alone — the gate's other
63
+ * half. `/v1/waitlist-experiments` is on this list because the review surface
64
+ * is gated on `multi-tenant || signal-collectors` (#5677), neither of which is
65
+ * enabled in this file's `workspace`-only cases; it is no longer
66
+ * multi-tenant-EXCLUSIVE, and its own matrix is
67
+ * `review-surface-registration.test.ts`.
68
+ */
69
+ const MULTI_TENANT_ONLY_ROUTES = [
70
+ { method: 'GET' as const, url: '/v1/workspaces' },
71
+ { method: 'GET' as const, url: '/v1/billing' },
72
+ { method: 'GET' as const, url: '/v1/waitlist-experiments' },
73
+ ];
74
+
75
+ async function withEnabledModules<T>(
76
+ enabled: string,
77
+ fn: () => Promise<T>,
78
+ ): Promise<T> {
79
+ const prior = process.env.AILK_ENABLED_MODULES;
80
+ process.env.AILK_ENABLED_MODULES = enabled;
81
+ try {
82
+ return await fn();
83
+ } finally {
84
+ if (prior === undefined) {
85
+ delete process.env.AILK_ENABLED_MODULES;
86
+ } else {
87
+ process.env.AILK_ENABLED_MODULES = prior;
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Every (method, path) pair Fastify's router holds, one per line. */
93
+ function routeLines(app: App): string[] {
94
+ return (app.printRoutes({ commonPrefix: false }) as string)
95
+ .split('\n')
96
+ .map((line: string) => line.trim())
97
+ .filter(Boolean);
98
+ }
99
+
100
+ describe('workspace-settings registration matrix (#5371 D2)', () => {
101
+ let app: App;
102
+
103
+ afterEach(async () => {
104
+ if (app) {
105
+ await app.close();
106
+ app = undefined;
107
+ }
108
+ delete process.env.AILK_ENABLED_MODULES;
109
+ });
110
+
111
+ it("with 'workspace' enabled and 'multi-tenant' disabled, the five settings routes resolve", async () => {
112
+ app = await withEnabledModules('workspace', () => build());
113
+
114
+ for (const { method, url } of SHARED_TENANT_ROUTES) {
115
+ const res = await app.inject({ method, url });
116
+ expect({ url, status: res.statusCode }).toEqual({ url, status: 401 });
117
+ }
118
+ });
119
+
120
+ it("with 'workspace' alone, billing / workspace-session / org routes stay unregistered", async () => {
121
+ app = await withEnabledModules('workspace', () => build());
122
+
123
+ for (const { method, url } of MULTI_TENANT_ONLY_ROUTES) {
124
+ const res = await app.inject({ method, url });
125
+ expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
126
+ }
127
+ });
128
+
129
+ it("with 'multi-tenant' enabled and 'workspace' disabled, every route resolves as before", async () => {
130
+ app = await withEnabledModules('multi-tenant', () => build());
131
+
132
+ for (const { method, url } of [
133
+ ...SHARED_TENANT_ROUTES,
134
+ ...MULTI_TENANT_ONLY_ROUTES,
135
+ ]) {
136
+ const res = await app.inject({ method, url });
137
+ expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
138
+ }
139
+ });
140
+
141
+ it('with BOTH enabled, each shared route registers exactly once', async () => {
142
+ // build() itself is the first assertion: Fastify throws
143
+ // FST_ERR_DUPLICATED_ROUTE at registration time, so a double-registration
144
+ // never reaches the router tree to be counted.
145
+ app = await withEnabledModules('workspace,multi-tenant', () => build());
146
+
147
+ const lines = routeLines(app);
148
+ for (const segment of ['theme', 'identity', 'seo', 'general', 'members']) {
149
+ const matches = lines.filter((line) => line.includes(segment));
150
+ expect({ segment, count: matches.length }).toEqual({
151
+ segment,
152
+ count: 1,
153
+ });
154
+ }
155
+
156
+ for (const { method, url } of [
157
+ ...SHARED_TENANT_ROUTES,
158
+ ...MULTI_TENANT_ONLY_ROUTES,
159
+ ]) {
160
+ const res = await app.inject({ method, url });
161
+ expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
162
+ }
163
+ });
164
+
165
+ it('with neither enabled, none of the five settings routes registers', async () => {
166
+ app = await withEnabledModules('', () => build());
167
+
168
+ for (const { method, url } of SHARED_TENANT_ROUTES) {
169
+ const res = await app.inject({ method, url });
170
+ expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
171
+ }
172
+ });
173
+ });
@@ -9,7 +9,7 @@
9
9
  * means the same (the OSS-strip build target); a list enables exactly the
10
10
  * modules it names.
11
11
  *
12
- * Recognized module keys: `docs`, `multi-tenant`, `scheduling`,
12
+ * Recognized module keys: `docs`, `multi-tenant`, `workspace`, `scheduling`,
13
13
  * `signal-collectors`, `commerce` (the `ModuleName` vocabulary
14
14
  * `packages/create-ailk/src/module-architecture.ts` already models via
15
15
  * `MODULE_SLICES`) plus `content-write` — the Pro
@@ -114,6 +114,9 @@ export const prisma = {
114
114
  // `findUnique` — used by the deliverable-resend service (#3998) to
115
115
  // resolve a grant's stored lead email.
116
116
  findUnique: jest.fn(),
117
+ // `findFirst` — the leads-inbox cursor lookup (#5678), which resolves a
118
+ // supplied cursor WITHIN the caller's scope before paging from it.
119
+ findFirst: jest.fn(),
117
120
  findMany: jest.fn(),
118
121
  updateMany: jest.fn(),
119
122
  },