create-ailk 0.4.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 (146) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +29 -1
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +50 -2
  6. package/dist/lib/extract-module-bundle.js +223 -15
  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.js +4 -0
  11. package/dist/module-architecture.d.ts +59 -8
  12. package/dist/module-architecture.js +334 -115
  13. package/dist/parse-args.js +6 -2
  14. package/dist/programmatic.d.ts +10 -0
  15. package/dist/programmatic.js +134 -19
  16. package/dist/site-chrome.d.ts +80 -0
  17. package/dist/site-chrome.js +135 -0
  18. package/dist/surfaces.d.ts +21 -6
  19. package/dist/surfaces.js +107 -32
  20. package/dist/template-manifest.d.ts +55 -3
  21. package/dist/template-manifest.js +181 -12
  22. package/dist/template-names.d.ts +1 -1
  23. package/dist/template-names.js +1 -0
  24. package/package.json +9 -2
  25. package/site-templates/_base/ailk-template.json +6 -0
  26. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  29. package/site-templates/marketing-site/ailk-template.json +11 -0
  30. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  31. package/site-templates/marketing-site/provenance.json +4 -0
  32. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  33. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  34. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  35. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  36. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  37. package/site-templates/minimalism-site/ailk-template.json +21 -0
  38. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  39. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  40. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  41. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  42. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  44. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  45. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  46. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  47. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  48. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  49. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  50. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  51. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  52. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  53. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  54. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  55. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  56. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  57. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  58. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  59. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  60. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  61. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  62. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  63. package/site-templates/minimalism-site/provenance.json +4 -0
  64. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  65. package/site-templates/saas-product/ailk-template.json +16 -0
  66. package/site-templates/saas-product/provenance.json +10 -0
  67. package/templates/.claude/skills/README.md +16 -25
  68. package/templates/.env.example +32 -7
  69. package/templates/.github/workflows/ci.yml +48 -0
  70. package/templates/README.md +3 -1
  71. package/templates/apps/api/CLAUDE.md +4 -1
  72. package/templates/apps/api/eslint.config.mjs +69 -0
  73. package/templates/apps/api/package.json +6 -2
  74. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  75. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  76. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +8 -1
  77. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  78. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  79. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  80. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  82. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  83. package/templates/apps/api/src/server.ts +82 -6
  84. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  85. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  86. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  87. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  88. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  89. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  90. package/templates/apps/mcp/CLAUDE.md +1 -1
  91. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  92. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  93. package/templates/apps/mcp/eslint.config.mjs +69 -0
  94. package/templates/apps/mcp/package.json +6 -2
  95. package/templates/apps/mcp/src/server.ts +2 -2
  96. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  97. package/templates/apps/mcp/src/tools/index.ts +2 -2
  98. package/templates/apps/web/.env.example +3 -3
  99. package/templates/apps/web/CLAUDE.md +2 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  101. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  103. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  104. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  105. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  106. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  107. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  108. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  109. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  111. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  112. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  113. package/templates/apps/web/components/google-analytics.tsx +18 -1
  114. package/templates/apps/web/eslint.config.mjs +69 -0
  115. package/templates/apps/web/jest.config.cjs +7 -15
  116. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  117. package/templates/apps/web/lib/nav-config.ts +27 -7
  118. package/templates/apps/web/lib/server-api.ts +1 -1
  119. package/templates/apps/web/lib/site-chrome.ts +29 -0
  120. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  121. package/templates/apps/web/messages/en.json +29 -2
  122. package/templates/apps/web/next.config.mjs +20 -0
  123. package/templates/apps/web/package.json +6 -2
  124. package/templates/apps/web/sentry.client.config.ts +131 -0
  125. package/templates/apps/web/sentry.edge.config.ts +72 -0
  126. package/templates/apps/web/sentry.server.config.ts +72 -0
  127. package/templates/apps/web/src/lib/routes.ts +13 -18
  128. package/templates/brand_assets/README.md +24 -0
  129. package/templates/content/en/AboutPage/about.mdx +160 -15
  130. package/templates/content/en/ContactPage/contact.mdx +90 -13
  131. package/templates/content/en/FAQPage/faq.mdx +62 -22
  132. package/templates/content/en/HomePage/home.mdx +244 -20
  133. package/templates/content/en/LandingPage/blog.mdx +83 -0
  134. package/templates/content/en/LandingPage/features.mdx +103 -0
  135. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  136. package/templates/database/CHANGELOG.md +7 -0
  137. package/templates/database/__tests__/seed.test.ts +1 -1
  138. package/templates/database/eslint.config.mjs +69 -0
  139. package/templates/database/package.json +7 -3
  140. package/templates/database/seed.ts +1 -1
  141. package/templates/package.json +1 -14
  142. package/versions.json +52 -0
  143. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  144. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  145. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  146. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @file list.test.ts
3
+ * Route-level inject tests for `GET /v1/leads` (#5678).
4
+ *
5
+ * The scoping proof lives one layer down, in
6
+ * `src/services/__tests__/leads-inbox.test.ts` — it seeds two sites against a
7
+ * realistically filtering prisma mock and asserts a caller for one reads none
8
+ * of the other's. This file covers what only the HTTP boundary can show: that
9
+ * an unauthenticated caller is refused before the service is ever reached,
10
+ * that the scope cannot be widened from the query string, and that the
11
+ * envelope and the 400 mapping are what `apps/api/CLAUDE.md` specifies.
12
+ */
13
+
14
+ // eslint-disable-next-line no-restricted-syntax -- the service is this route's own collaborator; mocking it keeps these tests on the HTTP boundary (the scoping proof is the service's own test).
15
+ jest.mock("../../../services/leads-inbox.js", () => ({
16
+ listOwnedLeads: jest.fn(),
17
+ }));
18
+
19
+ import type { FastifyInstance } from "fastify";
20
+
21
+ import { listOwnedLeads } from "../../../services/leads-inbox.js";
22
+ import { leadListRoute } from "../list";
23
+
24
+ const listOwnedLeadsMock = listOwnedLeads as jest.Mock;
25
+
26
+ const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
27
+ const PAGE = {
28
+ items: [
29
+ {
30
+ id: "lead_00000000-0000-7000-8000-0000000000d1",
31
+ name: "Ada Lovelace",
32
+ email: "ada@example.com",
33
+ source: "web",
34
+ createdAt: "2026-09-15T12:00:00.000Z",
35
+ },
36
+ ],
37
+ nextCursor: null,
38
+ };
39
+
40
+ /**
41
+ * Builds the route with `request.userId` pre-set, standing in for the
42
+ * `authPreHandlerPlugin` that decorates it in `server.ts`'s `authScope`.
43
+ * `userId: undefined` is the anonymous case — the same state the decorator
44
+ * leaves behind when no session resolves.
45
+ */
46
+ async function buildApp(userId: string | undefined): Promise<FastifyInstance> {
47
+ const fastify = require("fastify")();
48
+ fastify.decorateRequest("userId", undefined);
49
+ fastify.addHook("onRequest", (request: { userId?: string }, _reply: unknown, done: () => void) => {
50
+ request.userId = userId;
51
+ done();
52
+ });
53
+ await fastify.register(leadListRoute);
54
+ return fastify;
55
+ }
56
+
57
+ describe("GET /v1/leads", () => {
58
+ let app: FastifyInstance;
59
+
60
+ beforeEach(() => {
61
+ jest.clearAllMocks();
62
+ listOwnedLeadsMock.mockResolvedValue({ ok: true, value: PAGE });
63
+ });
64
+
65
+ afterEach(async () => {
66
+ await app.close();
67
+ });
68
+
69
+ it("401s an unauthenticated caller without touching the read service", async () => {
70
+ app = await buildApp(undefined);
71
+
72
+ const res = await app.inject({ method: "GET", url: "/v1/leads" });
73
+
74
+ expect(res.statusCode).toBe(401);
75
+ expect(res.json()).toEqual({
76
+ ok: false,
77
+ error: { code: "unauthorized", message: "Authentication required" },
78
+ });
79
+ expect(listOwnedLeadsMock).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("returns the page in the { ok: true, data } envelope", async () => {
83
+ app = await buildApp(USER_ID);
84
+
85
+ const res = await app.inject({ method: "GET", url: "/v1/leads" });
86
+
87
+ expect(res.statusCode).toBe(200);
88
+ expect(res.json()).toEqual({ ok: true, data: PAGE });
89
+ expect(listOwnedLeadsMock).toHaveBeenCalledWith({
90
+ userId: USER_ID,
91
+ cursor: undefined,
92
+ limit: 50,
93
+ });
94
+ });
95
+
96
+ it("ignores a caller-supplied siteId — the scope cannot be widened from the wire", async () => {
97
+ app = await buildApp(USER_ID);
98
+
99
+ const res = await app.inject({
100
+ method: "GET",
101
+ url: "/v1/leads?siteId=someone-elses-site&workspaceId=another",
102
+ });
103
+
104
+ expect(res.statusCode).toBe(200);
105
+ // The service is called with the caller's identity and the paging
106
+ // parameters only — no site, org or workspace value reaches it.
107
+ expect(listOwnedLeadsMock).toHaveBeenCalledWith({
108
+ userId: USER_ID,
109
+ cursor: undefined,
110
+ limit: 50,
111
+ });
112
+ });
113
+
114
+ it("400s a malformed cursor before any read", async () => {
115
+ app = await buildApp(USER_ID);
116
+
117
+ const res = await app.inject({ method: "GET", url: "/v1/leads?cursor=not-a-lead-id" });
118
+
119
+ expect(res.statusCode).toBe(400);
120
+ expect(res.json().error.code).toBe("validation_failed");
121
+ expect(listOwnedLeadsMock).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("400s a limit above the 200 ceiling", async () => {
125
+ app = await buildApp(USER_ID);
126
+
127
+ const res = await app.inject({ method: "GET", url: "/v1/leads?limit=1000" });
128
+
129
+ expect(res.statusCode).toBe(400);
130
+ expect(listOwnedLeadsMock).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("maps the service's invalid_cursor to a 400, not a 404 or a silent page 1", async () => {
134
+ app = await buildApp(USER_ID);
135
+ listOwnedLeadsMock.mockResolvedValue({
136
+ ok: false,
137
+ error: { kind: "invalid_cursor", message: "cursor does not resolve within this scope" },
138
+ });
139
+
140
+ const res = await app.inject({
141
+ method: "GET",
142
+ url: "/v1/leads?cursor=lead_00000000-0000-7000-8000-0000000000d9",
143
+ });
144
+
145
+ expect(res.statusCode).toBe(400);
146
+ expect(res.json()).toEqual({
147
+ ok: false,
148
+ error: {
149
+ code: "validation_failed",
150
+ message: "cursor does not resolve within this scope",
151
+ },
152
+ });
153
+ });
154
+
155
+ it("500s an internal error without surfacing its detail", async () => {
156
+ app = await buildApp(USER_ID);
157
+ listOwnedLeadsMock.mockResolvedValue({
158
+ ok: false,
159
+ error: { kind: "internal_error", message: "connect ECONNREFUSED 127.0.0.1:5432" },
160
+ });
161
+
162
+ const res = await app.inject({ method: "GET", url: "/v1/leads" });
163
+
164
+ expect(res.statusCode).toBe(500);
165
+ expect(res.json()).toEqual({
166
+ ok: false,
167
+ error: { code: "internal_error", message: "Failed to read leads" },
168
+ });
169
+ expect(res.body).not.toContain("ECONNREFUSED");
170
+ });
171
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The leads INBOX read (#5678).
3
+ *
4
+ * Routes:
5
+ * GET /v1/leads?cursor=&limit= — the caller's own sites' leads, newest first
6
+ *
7
+ * Registered INSIDE `server.ts`'s `authScope` (auth only — NOT the inner
8
+ * `tenantScope`, which the multi-tenant module owns), beside
9
+ * `/v1/waitlist-signups/me` and `/v1/project-listings/me`. Lead capture is a
10
+ * never-gate surface at every level, so reading a lead back must be too: this
11
+ * route is registered unconditionally, not behind an `isModuleEnabled()` gate.
12
+ * Registering everywhere is not the same as RETURNING a row everywhere —
13
+ * `services/leads-inbox.ts` § "What it takes for this to return anything"
14
+ * names the two deployment preconditions, both of which fail empty, never
15
+ * wide.
16
+ *
17
+ * Identity is `request.userId` ONLY. There is no site, org or workspace
18
+ * parameter — the query schema declares `cursor` and `limit` and nothing else
19
+ * — so the scope cannot be widened from the wire. `services/leads-inbox.ts`
20
+ * resolves it from the caller's own memberships through
21
+ * `services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` and filters every
22
+ * read on `siteId ∈ workspaceIds`.
23
+ *
24
+ * Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
25
+ * `{ ok: false, error: { code, message } }`, real HTTP status codes:
26
+ * 400 validation_failed · 401 unauthorized · 500 internal_error.
27
+ *
28
+ * No response-contract re-parse (the `waitlist-experiments/respond.ts`
29
+ * `sendValidated` net): the five fields of a row are assembled one by one in
30
+ * `leads-inbox.ts` from five explicitly selected columns, with no
31
+ * pass-through of a stored value that could carry an unexpected shape — so
32
+ * there is no drift for that net to catch.
33
+ *
34
+ * API↔MCP parity: identity-bound (the session IS the scope; the OSS MCP
35
+ * surface is identity-blind) — no MCP twin (#3591 exception, the same shape as
36
+ * `/v1/workspaces/*`, `/v1/billing/*` and `/v1/waitlist-experiments/*`).
37
+ */
38
+ import { zLeadId } from "@working-theory/ids";
39
+ import type { FastifyPluginAsync, FastifyReply } from "fastify";
40
+ import { z } from "zod";
41
+
42
+ import { listOwnedLeads } from "../../services/leads-inbox.js";
43
+
44
+ const DEFAULT_LIMIT = 50;
45
+ const MAX_LIMIT = 200;
46
+
47
+ const emptyToUndefined = (val: unknown) => (val === "" ? undefined : val);
48
+
49
+ /** Boundary validation, mirroring `waitlist-experiments/signups.ts`'s query schema. */
50
+ const listQuerySchema = z.object({
51
+ cursor: z.preprocess(emptyToUndefined, zLeadId.optional()),
52
+ limit: z.preprocess(
53
+ emptyToUndefined,
54
+ z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
55
+ ),
56
+ });
57
+
58
+ function unauthorized(reply: FastifyReply) {
59
+ return reply.status(401).send({
60
+ ok: false,
61
+ error: { code: "unauthorized", message: "Authentication required" },
62
+ });
63
+ }
64
+
65
+ function invalidQuery(reply: FastifyReply, message: string) {
66
+ return reply.status(400).send({
67
+ ok: false,
68
+ error: { code: "validation_failed", message },
69
+ });
70
+ }
71
+
72
+ export const leadListRoute: FastifyPluginAsync = async (fastify) => {
73
+ fastify.get("/v1/leads", async (request, reply) => {
74
+ const userId = request.userId;
75
+ if (!userId) return unauthorized(reply);
76
+
77
+ const query = listQuerySchema.safeParse(request.query);
78
+ if (!query.success) return invalidQuery(reply, "Invalid query parameters");
79
+
80
+ const result = await listOwnedLeads({
81
+ userId,
82
+ cursor: query.data.cursor,
83
+ limit: query.data.limit ?? DEFAULT_LIMIT,
84
+ });
85
+
86
+ if (!result.ok) {
87
+ if (result.error.kind === "invalid_cursor") {
88
+ return invalidQuery(reply, result.error.message);
89
+ }
90
+ // Do not surface internal error detail — log it server-side only, and
91
+ // with no lead field in the log line (the leads PII boundary).
92
+ request.log.error(
93
+ { route: "leads/list", error: result.error.message },
94
+ "leads list failed",
95
+ );
96
+ return reply.status(500).send({
97
+ ok: false,
98
+ error: { code: "internal_error", message: "Failed to read leads" },
99
+ });
100
+ }
101
+
102
+ return reply.status(200).send({ ok: true, data: result.value });
103
+ });
104
+ };
@@ -20,6 +20,7 @@ import { registerErrorSerializer } from "./lib/error-serializer.js";
20
20
  import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
21
21
  import { loadSlice } from "./lib/slice-load.js";
22
22
  import { authPreHandlerPlugin } from "./middleware/auth.js";
23
+ import { defaultTenantPreHandlerPlugin } from "./middleware/default-tenant.js";
23
24
  import {
24
25
  rateLimitMiddleware,
25
26
  DEFAULT_PER_IP_PER_MINUTE_STRICT,
@@ -49,6 +50,7 @@ import {
49
50
  leadSubmitRoute,
50
51
  leadSubscribeRoute,
51
52
  } from "./routes/leads/index.js";
53
+ import { leadListRoute } from "./routes/leads/list.js";
52
54
  import {
53
55
  registerTestAuthRoute,
54
56
  shouldMountTestAuth,
@@ -334,6 +336,27 @@ async function tenantPreHandlerSlicePlugin(): Promise<FastifyPluginCallback> {
334
336
  return plugin;
335
337
  }
336
338
 
339
+ /**
340
+ * The tenant guard THIS build runs (#5677). Exactly one of the two, never
341
+ * both: the multi-tenant module's `resolveActiveTenant` guard when the module
342
+ * is enabled, and the base-build default-workspace guard
343
+ * (`middleware/default-tenant.ts`) otherwise.
344
+ *
345
+ * Precedence, not parallelism. On a build WITH the module this returns the
346
+ * identical plugin every tenant scope registered before, so multi-tenant
347
+ * resolution and behavior are unchanged. On a build without it, the base guard
348
+ * resolves the seeded default Workspace (`database/tenancy.ts`) under the same
349
+ * default-deny posture — which is what makes the already-built review
350
+ * dashboard reachable at Pro instead of leaving its routes unregistered.
351
+ *
352
+ * The base guard is a STATIC import, not a slice load: it always ships.
353
+ */
354
+ async function tenantPreHandlerForBuild(): Promise<FastifyPluginCallback> {
355
+ return isModuleEnabled("multi-tenant")
356
+ ? await tenantPreHandlerSlicePlugin()
357
+ : defaultTenantPreHandlerPlugin;
358
+ }
359
+
337
360
  /**
338
361
  * The commerce boot-time sandbox guard — the one other slice entry that is
339
362
  * not a route plugin, mirroring `tenantPreHandlerSlicePlugin()` above (D3).
@@ -527,6 +550,18 @@ export async function build() {
527
550
  );
528
551
  }
529
552
 
553
+ // The leads INBOX read (#5678) — auth only, NOT tenant-guarded, for the
554
+ // same reason as the three reads below: the tenant scope is registered
555
+ // only when the multi-tenant module is present, and lead capture is a
556
+ // never-gate surface at EVERY level, so reading a lead back cannot be
557
+ // gated on a module either. Registered unconditionally — it is the one
558
+ // route in this scope with no `isModuleEnabled()` gate. Identity is
559
+ // `request.userId`; the scope is resolved from the caller's own
560
+ // memberships by `services/leads-inbox.ts` through
561
+ // `services/owner-workspaces.ts`'s `resolveOwnerWorkspaces`, never from a
562
+ // request parameter.
563
+ await authScope.register(leadListRoute);
564
+
530
565
  // Respondent signup read (#4731) — auth only, NOT tenant-guarded: a
531
566
  // respondent has no workspace membership and the tenant guard is
532
567
  // default-deny. Identity is request.userId; the service re-checks
@@ -580,6 +615,35 @@ export async function build() {
580
615
  await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
581
616
  // Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
582
617
  await tenantScope.register(await sliceRoutePlugin("billingRoutes"));
618
+ });
619
+ }
620
+
621
+ // REVIEW-SURFACE routes — a sibling tenant scope, gated on EITHER module
622
+ // (#5677). The waitlist operator dashboard and the project-listings
623
+ // dashboard were registered inside the multi-tenant scope above, which was
624
+ // too narrow: `signal-collectors` (Pro) is the module that SELLS the
625
+ // instruments these routes read back — flows, deliverables, project
626
+ // listings, waitlist experiments — so a Pro customer could collect
627
+ // submissions and not read them. That was an accident of registration
628
+ // scope, not a pricing decision (#5677). Widening the gate is the
629
+ // correction.
630
+ //
631
+ // This is a REGISTRATION change, not a tenant-isolation change. The scope
632
+ // registers `tenantPreHandlerForBuild()`, which on a multi-tenant build is
633
+ // byte-for-byte the guard these two route plugins already ran behind, and
634
+ // on a build without the module is the base-build guard with the same
635
+ // default-deny posture over the seeded default Workspace. Each plugin
636
+ // registers exactly once — they live here and nowhere else, so the scopes
637
+ // cannot collide on a path — and every OTHER route in the scope above
638
+ // (workspaceSessionRoutes, sessionRoutes, billingRoutes) stays
639
+ // multi-tenant-only: those are Organization/Workspace administration, not
640
+ // a site's own submissions.
641
+ if (
642
+ isModuleEnabled("multi-tenant") ||
643
+ isModuleEnabled("signal-collectors")
644
+ ) {
645
+ await authScope.register(async (tenantScope) => {
646
+ await tenantScope.register(await tenantPreHandlerForBuild());
583
647
  // Waitlist operator-dashboard read API (#4665 D7, B0) — tenant-scoped
584
648
  // list/comparison/signups reads over A1's waitlist-experiment rows.
585
649
  await tenantScope.register(
@@ -603,18 +667,30 @@ export async function build() {
603
667
  // original mis-scoping twice.
604
668
  //
605
669
  // This is a REGISTRATION change, not a tenant-isolation change: the scope
606
- // registers its own `tenantPreHandlerSlicePlugin()`, the same default-deny
607
- // guard the scope above uses, with the same `resolveActiveTenant` behind
608
- // it. Every other route in that scope (workspaceSessionRoutes,
609
- // sessionRoutes, billingRoutes, waitlistExperimentRoutes, the
610
- // project-listings tenant routes) stays multi-tenant-only.
670
+ // registers its own `tenantPreHandlerForBuild()`, the same default-deny
671
+ // guard the scopes above use. The remaining routes in the multi-tenant
672
+ // scope above (workspaceSessionRoutes, sessionRoutes, billingRoutes) stay
673
+ // multi-tenant-only.
674
+ //
675
+ // #5677's selector fixes ONE of this scope's two stripped-scaffold
676
+ // problems and not the other, so do not read it as making a
677
+ // `--modules workspace` scaffold work. It removes the guard's dependency
678
+ // on `./middleware/tenant.js` (a multi-tenant slice file, which threw
679
+ // ERR_MODULE_NOT_FOUND during construction). The three route plugins
680
+ // below are still reached through slice loads, and
681
+ // `routes/workspaces/config.ts` remains a multi-tenant `ownedFiles` entry
682
+ // — `general.ts` and `members.ts` are workspace-owned, `config.ts` is not
683
+ // — so that scaffold still has no `routes/workspaces/` directory and
684
+ // still throws here. Fixing it is the same one-line class of change #5677
685
+ // made for the review surface: co-own `config.ts` under `workspace` in
686
+ // MODULE_SLICES. Out of scope for #5677, which owns the review surface.
611
687
  //
612
688
  // A build with BOTH modules enabled registers each of the three route
613
689
  // plugins exactly once: they live here and nowhere else, so the two
614
690
  // scopes cannot collide on a path.
615
691
  if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
616
692
  await authScope.register(async (tenantScope) => {
617
- await tenantScope.register(await tenantPreHandlerSlicePlugin());
693
+ await tenantScope.register(await tenantPreHandlerForBuild());
618
694
  await tenantScope.register(
619
695
  await sliceRoutePlugin("workspaceConfigRoutes"),
620
696
  );