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
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @file leads-inbox.test.ts
3
+ * @description Unit tests for the leads-inbox read service (#5678).
4
+ *
5
+ * The load-bearing case is ISOLATION: this endpoint returns contact PII, so
6
+ * the claim "a caller cannot read another site's leads" is tested, not argued.
7
+ *
8
+ * External dependency boundary mocked:
9
+ * - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts` manual
10
+ * mock (`jest.mock("../../lib/prisma.js")`).
11
+ *
12
+ * `prisma.lead.findMany` / `findFirst` are given a REALISTIC filtering
13
+ * implementation over an in-memory fixture "table" rather than a static
14
+ * `mockResolvedValue`, mirroring `lead-promotion.test.ts`. That is what makes
15
+ * the isolation test real: a bug that widened the `siteId` match set would
16
+ * surface the other site's lead in the returned page, not merely in an
17
+ * argument-shape assertion.
18
+ *
19
+ * `tenant-context.ts`'s `listTenancy` runs REAL — a pure read over the mocked
20
+ * `prisma.membership.findMany` — so these tests also exercise the
21
+ * "resolve through `lead-promotion.ts`'s `resolveOwnerWorkspaces`" contract
22
+ * rather than re-stubbing it.
23
+ */
24
+
25
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
26
+ jest.mock("../../lib/prisma.js");
27
+
28
+ import type { Id } from "@working-theory/ids";
29
+
30
+ import { prisma } from "../../lib/prisma.js";
31
+ import { listOwnedLeads } from "../leads-inbox";
32
+
33
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
34
+
35
+ const USER_ID = "user_00000000-0000-7000-8000-0000000000a1" as Id<"user">;
36
+ const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
37
+ /** The caller's own workspace — and therefore the `siteId` of their own leads. */
38
+ const OWN_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
39
+ /** A DIFFERENT organization's workspace. The caller holds no membership in it. */
40
+ const OTHER_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
41
+
42
+ type FakeLeadRow = {
43
+ id: string;
44
+ siteId: string;
45
+ email: string;
46
+ source: string;
47
+ payload: unknown;
48
+ createdAt: Date;
49
+ };
50
+
51
+ /** Shapes `prisma.membership.findMany`'s return — what `listTenancy` reads. */
52
+ function membershipsWithRole(role: string) {
53
+ return [
54
+ {
55
+ role,
56
+ organization: {
57
+ id: OWN_ORG_ID,
58
+ name: "Acme",
59
+ slug: "acme",
60
+ workspaces: [{ id: OWN_SITE_ID, name: "Acme HQ", slug: "acme-hq" }],
61
+ },
62
+ },
63
+ ];
64
+ }
65
+
66
+ function lead(overrides: Partial<FakeLeadRow> & { id: string }): FakeLeadRow {
67
+ return {
68
+ siteId: OWN_SITE_ID,
69
+ email: "contact@example.com",
70
+ source: "web",
71
+ payload: { name: "Ada Lovelace", message: "please call me", phone: "555" },
72
+ createdAt: new Date("2026-09-15T12:00:00.000Z"),
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Wires `prisma.lead.findFirst` / `findMany` to a realistic in-memory "table"
79
+ * filtered by the `where` the service passes — so a row outside `siteId.in` is
80
+ * genuinely never returned, exactly as a real WHERE clause would exclude it.
81
+ */
82
+ function seedLeadTable(rows: FakeLeadRow[]) {
83
+ const table = rows.map((r) => ({ ...r }));
84
+
85
+ type Where = {
86
+ siteId: { in: string[] };
87
+ id?: string;
88
+ OR?: Array<{
89
+ createdAt?: Date | { lt: Date };
90
+ id?: { lt: string };
91
+ }>;
92
+ };
93
+
94
+ const inScope = (row: FakeLeadRow, where: Where) =>
95
+ where.siteId.in.includes(row.siteId) &&
96
+ (where.id === undefined || row.id === where.id);
97
+
98
+ const afterCursor = (row: FakeLeadRow, where: Where) => {
99
+ if (!where.OR) return true;
100
+ return where.OR.some((clause) => {
101
+ if (clause.createdAt instanceof Date) {
102
+ return (
103
+ row.createdAt.getTime() === clause.createdAt.getTime() &&
104
+ clause.id !== undefined &&
105
+ row.id < clause.id.lt
106
+ );
107
+ }
108
+ const lt = (clause.createdAt as { lt: Date }).lt;
109
+ return row.createdAt.getTime() < lt.getTime();
110
+ });
111
+ };
112
+
113
+ (prisma.lead.findFirst as jest.Mock).mockImplementation(
114
+ async (args: { where: Where }) =>
115
+ table.find((row) => inScope(row, args.where)) ?? null,
116
+ );
117
+
118
+ (prisma.lead.findMany as jest.Mock).mockImplementation(
119
+ async (args: { where: Where; take: number }) =>
120
+ table
121
+ .filter((row) => inScope(row, args.where) && afterCursor(row, args.where))
122
+ .sort((a, b) =>
123
+ b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : -1),
124
+ )
125
+ .slice(0, args.take),
126
+ );
127
+
128
+ return table;
129
+ }
130
+
131
+ describe("listOwnedLeads", () => {
132
+ // The workspace resolution is module-conditional (isModuleEnabled
133
+ // 'multi-tenant' — see lead-promotion.ts), so every test that expects rows
134
+ // needs the module explicitly enabled: AILK_ENABLED_MODULES unset means no
135
+ // paid modules (D-44, #5368).
136
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
137
+
138
+ beforeEach(() => {
139
+ jest.clearAllMocks();
140
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
141
+ });
142
+
143
+ afterAll(() => {
144
+ if (priorEnabledModules === undefined) {
145
+ delete process.env.AILK_ENABLED_MODULES;
146
+ } else {
147
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
148
+ }
149
+ });
150
+
151
+ // ── ISOLATION (load-bearing) ───────────────────────────────────────────────
152
+ it("never returns a lead whose siteId belongs to another site", async () => {
153
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
154
+ membershipsWithRole("owner"), // the caller belongs ONLY to OWN_ORG_ID
155
+ );
156
+ seedLeadTable([
157
+ lead({
158
+ id: "lead_own_1",
159
+ siteId: OWN_SITE_ID,
160
+ email: "mine@example.com",
161
+ createdAt: new Date("2026-09-15T12:00:00.000Z"),
162
+ }),
163
+ lead({
164
+ id: "lead_other_1",
165
+ siteId: OTHER_SITE_ID,
166
+ email: "theirs@example.com",
167
+ createdAt: new Date("2026-09-15T13:00:00.000Z"), // NEWER — would sort first
168
+ }),
169
+ ]);
170
+
171
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
172
+
173
+ expect(result.ok).toBe(true);
174
+ if (!result.ok) return;
175
+ expect(result.value.items.map((row) => row.id)).toEqual(["lead_own_1"]);
176
+ expect(JSON.stringify(result.value)).not.toContain("theirs@example.com");
177
+ expect(JSON.stringify(result.value)).not.toContain(OTHER_SITE_ID);
178
+ // The scope reaches the DB as a filter, not a post-filter in JS.
179
+ expect(prisma.lead.findMany).toHaveBeenCalledWith(
180
+ expect.objectContaining({ where: expect.objectContaining({ siteId: { in: [OWN_SITE_ID] } }) }),
181
+ );
182
+ });
183
+
184
+ it("returns an empty page when the caller belongs to no workspace at all", async () => {
185
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
186
+ seedLeadTable([lead({ id: "lead_other_1", siteId: OTHER_SITE_ID })]);
187
+
188
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
189
+
190
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
191
+ // No workspace means no query at all — not a query with an empty `in`.
192
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("returns an empty page for a `member` — the role floor is owner|admin", async () => {
196
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
197
+ membershipsWithRole("member"),
198
+ );
199
+ seedLeadTable([lead({ id: "lead_own_1", siteId: OWN_SITE_ID })]);
200
+
201
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
202
+
203
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
204
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("rejects a cursor that resolves outside the caller's scope", async () => {
208
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
209
+ membershipsWithRole("admin"),
210
+ );
211
+ seedLeadTable([
212
+ lead({ id: "lead_own_1", siteId: OWN_SITE_ID }),
213
+ lead({ id: "lead_other_1", siteId: OTHER_SITE_ID }),
214
+ ]);
215
+
216
+ const result = await listOwnedLeads({
217
+ userId: USER_ID,
218
+ cursor: "lead_other_1",
219
+ limit: 50,
220
+ });
221
+
222
+ expect(result).toEqual({
223
+ ok: false,
224
+ error: {
225
+ kind: "invalid_cursor",
226
+ message: "cursor does not resolve within this scope",
227
+ },
228
+ });
229
+ // The rejection is indistinguishable from "no such lead" — a foreign
230
+ // cursor is never a probe that confirms another site's row exists.
231
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
232
+ });
233
+
234
+ // ── Row shape (the PII floor) ──────────────────────────────────────────────
235
+ it("returns exactly the five rendered fields — never the rest of the record", async () => {
236
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
237
+ membershipsWithRole("owner"),
238
+ );
239
+ seedLeadTable([
240
+ lead({
241
+ id: "lead_own_1",
242
+ email: "ada@example.com",
243
+ source: "conversational-intake",
244
+ payload: { name: "Ada Lovelace", message: "please call me", phone: "555-0100" },
245
+ }),
246
+ ]);
247
+
248
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
249
+
250
+ expect(result.ok).toBe(true);
251
+ if (!result.ok) return;
252
+ expect(result.value.items).toEqual([
253
+ {
254
+ id: "lead_own_1",
255
+ name: "Ada Lovelace",
256
+ email: "ada@example.com",
257
+ source: "conversational-intake",
258
+ createdAt: "2026-09-15T12:00:00.000Z",
259
+ },
260
+ ]);
261
+ // The rest of `payload` is never on the wire.
262
+ const serialized = JSON.stringify(result.value);
263
+ expect(serialized).not.toContain("please call me");
264
+ expect(serialized).not.toContain("555-0100");
265
+ expect(serialized).not.toContain("siteId");
266
+ });
267
+
268
+ it("truncates an unbounded name — one anonymous write cannot size the page", async () => {
269
+ // PR #5705 security review, finding 1: `boundedLeadPayload` bounds the
270
+ // payload at 64 KEYS and nothing else, and `POST /v1/leads/submit` is
271
+ // anonymous with no ownership check on `siteId`, so a stranger can plant a
272
+ // ~1 MB `name` in the owner's inbox. Ordering is `createdAt desc` and this
273
+ // unit ships no delete surface, so it would sit on page one for good.
274
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
275
+ membershipsWithRole("owner"),
276
+ );
277
+ seedLeadTable([
278
+ lead({ id: "lead_own_1", payload: { name: "A".repeat(900_000) } }),
279
+ ]);
280
+
281
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
282
+
283
+ expect(result.ok).toBe(true);
284
+ if (!result.ok) return;
285
+ expect(result.value.items[0]?.name).toBe("A".repeat(200));
286
+ expect(JSON.stringify(result.value).length).toBeLessThan(1000);
287
+ });
288
+
289
+ it("reads `name` as null when the site's form captured none", async () => {
290
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
291
+ membershipsWithRole("owner"),
292
+ );
293
+ seedLeadTable([lead({ id: "lead_own_1", payload: { company: "Acme" } })]);
294
+
295
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
296
+
297
+ expect(result.ok).toBe(true);
298
+ if (!result.ok) return;
299
+ expect(result.value.items[0]?.name).toBeNull();
300
+ });
301
+
302
+ // ── Pagination ─────────────────────────────────────────────────────────────
303
+ it("pages newest first and hands back the last row's id as nextCursor", async () => {
304
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
305
+ membershipsWithRole("owner"),
306
+ );
307
+ seedLeadTable([
308
+ lead({ id: "lead_a", createdAt: new Date("2026-09-15T10:00:00.000Z") }),
309
+ lead({ id: "lead_b", createdAt: new Date("2026-09-15T11:00:00.000Z") }),
310
+ lead({ id: "lead_c", createdAt: new Date("2026-09-15T12:00:00.000Z") }),
311
+ ]);
312
+
313
+ const first = await listOwnedLeads({ userId: USER_ID, limit: 2 });
314
+ expect(first.ok).toBe(true);
315
+ if (!first.ok) return;
316
+ expect(first.value.items.map((row) => row.id)).toEqual(["lead_c", "lead_b"]);
317
+ expect(first.value.nextCursor).toBe("lead_b");
318
+
319
+ const second = await listOwnedLeads({
320
+ userId: USER_ID,
321
+ cursor: first.value.nextCursor ?? undefined,
322
+ limit: 2,
323
+ });
324
+ expect(second.ok).toBe(true);
325
+ if (!second.ok) return;
326
+ expect(second.value.items.map((row) => row.id)).toEqual(["lead_a"]);
327
+ expect(second.value.nextCursor).toBeNull();
328
+ });
329
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Default-tenant resolution — the BASE-build half of the tenant guard (#5677).
3
+ *
4
+ * A single-tenant site is a ONE-WORKSPACE TENANT, not a special case. The
5
+ * default Organization / Workspace / operator Membership are already declared
6
+ * as fixed reserved-range sentinels in `database/tenancy.ts` and already
7
+ * seeded by `database/seed.ts`, so there is nothing to synthesize here — this
8
+ * file resolves the seeded default and nothing else.
9
+ *
10
+ * WHY IT LIVES IN THE BASE BUILD. `services/tenant-context.ts` and
11
+ * `middleware/tenant.ts` are multi-tenant SLICE files (`// oss: false`), so on
12
+ * a build without that module they are not present and the routes behind them
13
+ * were never REGISTERED — not denied (#5677). The default
14
+ * resolution and the `TenantContext` shape it yields are needed on every
15
+ * build, so they live here, in a file that always ships; the multi-tenant
16
+ * slice imports this one rather than carrying its own copy, which is what
17
+ * keeps the two paths from drifting.
18
+ *
19
+ * PRECEDENCE, NOT PARALLELISM. When the multi-tenant module is present,
20
+ * `resolveActiveTenant` (the slice) is the entry point and calls
21
+ * `resolveDefaultTenant` only on the branch it always called it on — the
22
+ * previously-unconditional-deny null-binding branch. Nothing about the
23
+ * multi-tenant path changes. Without the module, `middleware/default-tenant.ts`
24
+ * calls this directly. There is one resolution, reached two ways, never two
25
+ * resolutions.
26
+ *
27
+ * DEFAULT-DENY IS THE FLOOR. `resolveDefaultTenant` returns `null` — never a
28
+ * grant — the instant the deployment is not unambiguously single-tenant
29
+ * (a second Workspace exists, or the sole Workspace is not the well-known
30
+ * default), and `null` again for a caller holding no Membership on the default
31
+ * Organization. Both branches were written for the multi-tenant slice by the
32
+ * owner-pin fix (docs/specs/single-tenant-owner-pin-spec.md, #3631) and are
33
+ * moved here BYTE-FOR-BYTE, not rewritten.
34
+ *
35
+ * This file is a sanctioned exception to architecture.yaml's tenant-scope
36
+ * forbidden pattern, for the same reason `tenant-context.ts` is: it is a
37
+ * tenancy-model boundary, not a domain-data reader.
38
+ */
39
+ import {
40
+ DEFAULT_ORG_ID,
41
+ DEFAULT_WORKSPACE_ID,
42
+ OPERATOR_MEMBERSHIP_ID,
43
+ } from "@working-theory/database/tenancy";
44
+ import { isSentinel, type Id } from "@working-theory/ids";
45
+
46
+ import { prisma } from "../lib/prisma.js";
47
+
48
+ export type MembershipRole = "owner" | "admin" | "member";
49
+
50
+ /**
51
+ * The tenant scope every domain query is bound to. Ids carry the ADR-0031
52
+ * brand; values originating from Prisma reads are brand-asserted at the read
53
+ * edge (the DB CHECK constraints are the runtime guarantee).
54
+ */
55
+ export interface TenantContext {
56
+ userId: Id<"user">;
57
+ organizationId: Id<"organization">;
58
+ workspaceId: Id<"workspace">;
59
+ role: MembershipRole;
60
+ }
61
+
62
+ export type TenantErrorKind = "forbidden" | "not_found" | "conflict";
63
+
64
+ export type TenantResult<T> =
65
+ | { ok: true; value: T }
66
+ | { ok: false; error: { kind: TenantErrorKind; message: string } };
67
+
68
+ export function isUniqueViolation(error: unknown): boolean {
69
+ return (
70
+ typeof error === "object" &&
71
+ error !== null &&
72
+ "code" in error &&
73
+ (error as { code?: unknown }).code === "P2002"
74
+ );
75
+ }
76
+
77
+ /**
78
+ * TOFU-binds the operator Membership for a single-tenant deployment (spec
79
+ * D1–D4) — the replacement for the deleted user-agnostic `owner` grant. The
80
+ * Membership is created with a fixed, well-known id
81
+ * (`OPERATOR_MEMBERSHIP_ID`); the `Membership.id` primary-key uniqueness —
82
+ * not a read-then-write "is this the first user?" guard — enforces exactly
83
+ * one owner under a concurrent first-login race. The losing caller's
84
+ * `create` raises `P2002`, swallowed here: that caller is then unbound and
85
+ * denied by the `membership.findUnique` the caller of this function runs
86
+ * next.
87
+ *
88
+ * Idempotent + at-most-once: a caller who already holds the Membership (a
89
+ * repeat call, or the deployment's steady state after the first bind) hits
90
+ * the same `P2002` path and is a no-op. Reachable ONLY from
91
+ * `resolveDefaultTenant`, itself gated by the
92
+ * `count === 1 && default-workspace-exists` check — unreachable from any
93
+ * multi-tenant state (`count > 1`).
94
+ *
95
+ * When `AILK_OPERATOR_EMAIL` is set (spec D4 — trimmed + lowercased before
96
+ * comparison), the bind is attempted ONLY for the caller whose `User.email`
97
+ * matches it; every other caller returns without writing and is denied
98
+ * downstream. An empty/whitespace-only `AILK_OPERATOR_EMAIL` normalizes to
99
+ * `""`, which is falsy — treated as unset (pure TOFU), so a blank pin can
100
+ * never "match" a blank `User.email`. When unset, the bind is pure TOFU —
101
+ * first caller wins.
102
+ */
103
+ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
104
+ const pinnedEmail = process.env.AILK_OPERATOR_EMAIL?.trim().toLowerCase();
105
+ if (pinnedEmail) {
106
+ const user = await prisma.user.findUnique({
107
+ where: { id: userId },
108
+ select: { email: true },
109
+ });
110
+ if (!user || user.email.trim().toLowerCase() !== pinnedEmail) {
111
+ return;
112
+ }
113
+ }
114
+ try {
115
+ await prisma.membership.create({
116
+ data: {
117
+ id: OPERATOR_MEMBERSHIP_ID,
118
+ organizationId: DEFAULT_ORG_ID,
119
+ userId,
120
+ role: "owner",
121
+ },
122
+ });
123
+ } catch (error) {
124
+ if (!isUniqueViolation(error)) {
125
+ throw error;
126
+ }
127
+ // Another caller already holds OPERATOR_MEMBERSHIP_ID (the TOFU race
128
+ // winner, or a prior deployment run) — swallow and fall through.
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The BASE-BUILD entry point: `resolveDefaultTenant` narrowed to the caller
134
+ * whose `User.email` matches `AILK_OPERATOR_EMAIL`, denying everyone when the
135
+ * pin is unset (#5693 security review, High finding).
136
+ *
137
+ * WHY THE BASE PATH NEEDS THIS AND THE MULTI-TENANT PATH DOES NOT. Registering
138
+ * the review surface on a module-less build made two things reachable together
139
+ * that were previously unreachable there: this resolution's TOFU operator bind,
140
+ * and `services/waitlist-dashboard.ts`'s `isPlatformOperator`, which grants
141
+ * `{kind: "all"}` — every site's rows, unfiltered — to an `owner` of the
142
+ * sentinel default Organization whenever `AILK_OPERATOR_EMAIL` is merely SET.
143
+ * That predicate never compares the caller to the pin; the pin is an on/off
144
+ * switch for it.
145
+ *
146
+ * Unpinned TOFU plus that predicate is an escalation. The site's auth
147
+ * auto-provisions a `User` on first login, so on a deployment with the pin
148
+ * unset the FIRST authenticated caller to touch a review route — any public
149
+ * respondent, not necessarily the operator — wins `OPERATOR_MEMBERSHIP_ID`.
150
+ * The real operator is then denied (their `create` raises `P2002`, swallowed),
151
+ * finds an empty dashboard, and sets `AILK_OPERATOR_EMAIL` to fix it — which
152
+ * flips the squatter, not the operator, to `{kind: "all"}`.
153
+ *
154
+ * So the base path requires the pin and requires the caller to BE it. Both
155
+ * halves matter: requiring the pin stops an unpinned race from seating anyone,
156
+ * and re-checking the caller's email on every request denies a squatter who
157
+ * was already bound before this code shipped — a Membership row alone is no
158
+ * longer enough. `bindOperatorMembership` below performs its own email check,
159
+ * but only at bind time and only for the bind; this is the one that gates the
160
+ * grant.
161
+ *
162
+ * The multi-tenant module is unaffected: `resolveActiveTenant` keeps calling
163
+ * `resolveDefaultTenant` directly, on exactly the branch it always called it
164
+ * on. This wrapper is reached only from `middleware/default-tenant.ts`, which
165
+ * only ever runs on a build without that module.
166
+ *
167
+ * `docs/specs/single-tenant-owner-pin-spec.md` D5 already recommended pinning;
168
+ * on this path it is mandatory rather than advisory, and unset fails closed.
169
+ *
170
+ * ONE KNOWN IMPRECISION, deliberate. `User.email` is `@unique` in Postgres,
171
+ * which is byte-exact, while this comparison lowercases both sides — so two
172
+ * distinct rows (`Operator@x.com` and `operator@x.com`) could both satisfy one
173
+ * pin. It is not reachable today: `apps/web/auth.ts` configures social
174
+ * providers only, so the address is provider-asserted and normalized rather
175
+ * than caller-chosen. It becomes reachable the moment a credential provider is
176
+ * enabled, and the durable fix is case-insensitive email uniqueness in the
177
+ * schema (citext, or lowercase-at-write) so "matches the pin" and "is unique"
178
+ * agree. Matching case-sensitively here instead would be worse: it would lock
179
+ * out an operator who typed their own address with different capitalisation.
180
+ */
181
+ export async function resolveDefaultTenantForOperator(
182
+ userId: Id<"user">,
183
+ ): Promise<TenantContext | null> {
184
+ const pinnedEmail = process.env.AILK_OPERATOR_EMAIL?.trim().toLowerCase();
185
+ if (!pinnedEmail) {
186
+ return null;
187
+ }
188
+ const user = await prisma.user.findUnique({
189
+ where: { id: userId },
190
+ select: { email: true, emailVerified: true },
191
+ });
192
+ // `emailVerified` is load-bearing wherever this codebase joins an identity
193
+ // to data by email — the respondent reads (`services/waitlist-signups.ts`,
194
+ // `services/flow-checkouts.ts`) refuse before any Lead read for the same
195
+ // reason. An unverified address is an unproven claim to the pinned one.
196
+ if (!user || !user.emailVerified) {
197
+ return null;
198
+ }
199
+ if (user.email.trim().toLowerCase() !== pinnedEmail) {
200
+ return null;
201
+ }
202
+ return resolveDefaultTenant(userId);
203
+ }
204
+
205
+ /**
206
+ * Resolves the default single-tenant scope when the deployment is
207
+ * unambiguously single-tenant — the default Workspace (`DEFAULT_WORKSPACE_ID`)
208
+ * exists AND it is the ONLY Workspace (`workspace.count() === 1`), the exact
209
+ * mirror of the Phase-1 provision guard's `count === 0` check (spec D2).
210
+ * Returns `null` (never grants) the instant a second Workspace exists, or the
211
+ * sole Workspace is not the well-known default — the caller falls through to
212
+ * its own default-deny in that case.
213
+ *
214
+ * Converges on the SAME Membership-based authz the multi-tenant path uses
215
+ * (spec D1 — the fix): attempts to TOFU-bind the caller as operator
216
+ * (`bindOperatorMembership`, a no-op once an operator is already bound or
217
+ * the caller doesn't match the optional env pin), then runs the real
218
+ * `membership.findUnique` check. A caller holding no Membership on the
219
+ * default Org — including every caller after the first once TOFU has bound
220
+ * someone else — resolves `null` here, and the caller denies. There is no
221
+ * user-agnostic `owner` grant.
222
+ *
223
+ * Formerly `resolveSingleTenantDefault` in `services/tenant-context.ts`
224
+ * (#3631); moved here verbatim by #5677 so the base build can reach it.
225
+ */
226
+ export async function resolveDefaultTenant(
227
+ userId: Id<"user">,
228
+ ): Promise<TenantContext | null> {
229
+ const workspaceCount = await prisma.workspace.count();
230
+ if (workspaceCount !== 1) {
231
+ return null;
232
+ }
233
+ const workspace = await prisma.workspace.findUnique({
234
+ where: { id: DEFAULT_WORKSPACE_ID },
235
+ select: { id: true, organizationId: true },
236
+ });
237
+ // The default workspace must hang off the sentinel default Organization
238
+ // (ADR-0031 rule 7): keyed on the reserved-range predicate, not a literal.
239
+ if (!workspace || !isSentinel(workspace.organizationId)) {
240
+ return null;
241
+ }
242
+ await bindOperatorMembership(userId);
243
+ const membership = await prisma.membership.findUnique({
244
+ where: {
245
+ organizationId_userId: {
246
+ organizationId: workspace.organizationId,
247
+ userId,
248
+ },
249
+ },
250
+ select: { role: true },
251
+ });
252
+ if (!membership) {
253
+ return null;
254
+ }
255
+ return {
256
+ userId,
257
+ organizationId: workspace.organizationId as Id<"organization">,
258
+ workspaceId: workspace.id as Id<"workspace">,
259
+ role: membership.role as MembershipRole,
260
+ };
261
+ }