create-ailk 0.18.0 → 0.19.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 (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -1,476 +0,0 @@
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 the rows to a `member` — `inbox-read` holds every role (#7636 D2)", 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.ok).toBe(true);
204
- if (!result.ok) return;
205
- expect(result.value.items.map((row) => row.id)).toEqual(["lead_own_1"]);
206
- });
207
-
208
- it("returns an empty page for a role outside the permission table — default deny", async () => {
209
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(
210
- membershipsWithRole("writer"),
211
- );
212
- seedLeadTable([lead({ id: "lead_own_1", siteId: OWN_SITE_ID })]);
213
-
214
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
215
-
216
- expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
217
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
218
- });
219
-
220
- it("rejects a cursor that resolves outside the caller's scope", async () => {
221
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(
222
- membershipsWithRole("admin"),
223
- );
224
- seedLeadTable([
225
- lead({ id: "lead_own_1", siteId: OWN_SITE_ID }),
226
- lead({ id: "lead_other_1", siteId: OTHER_SITE_ID }),
227
- ]);
228
-
229
- const result = await listOwnedLeads({
230
- userId: USER_ID,
231
- cursor: "lead_other_1",
232
- limit: 50,
233
- });
234
-
235
- expect(result).toEqual({
236
- ok: false,
237
- error: {
238
- kind: "invalid_cursor",
239
- message: "cursor does not resolve within this scope",
240
- },
241
- });
242
- // The rejection is indistinguishable from "no such lead" — a foreign
243
- // cursor is never a probe that confirms another site's row exists.
244
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
245
- });
246
-
247
- // ── Row shape (the PII floor) ──────────────────────────────────────────────
248
- it("returns exactly the five rendered fields — never the rest of the record", async () => {
249
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(
250
- membershipsWithRole("owner"),
251
- );
252
- seedLeadTable([
253
- lead({
254
- id: "lead_own_1",
255
- email: "ada@example.com",
256
- source: "conversational-intake",
257
- payload: { name: "Ada Lovelace", message: "please call me", phone: "555-0100" },
258
- }),
259
- ]);
260
-
261
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
262
-
263
- expect(result.ok).toBe(true);
264
- if (!result.ok) return;
265
- expect(result.value.items).toEqual([
266
- {
267
- id: "lead_own_1",
268
- name: "Ada Lovelace",
269
- email: "ada@example.com",
270
- source: "conversational-intake",
271
- createdAt: "2026-09-15T12:00:00.000Z",
272
- },
273
- ]);
274
- // The rest of `payload` is never on the wire.
275
- const serialized = JSON.stringify(result.value);
276
- expect(serialized).not.toContain("please call me");
277
- expect(serialized).not.toContain("555-0100");
278
- expect(serialized).not.toContain("siteId");
279
- });
280
-
281
- it("truncates an unbounded name — one anonymous write cannot size the page", async () => {
282
- // PR #5705 security review, finding 1: `boundedLeadPayload` bounds the
283
- // payload at 64 KEYS and nothing else, and `POST /v1/leads/submit` is
284
- // anonymous with no ownership check on `siteId`, so a stranger can plant a
285
- // ~1 MB `name` in the owner's inbox. Ordering is `createdAt desc` and this
286
- // unit ships no delete surface, so it would sit on page one for good.
287
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(
288
- membershipsWithRole("owner"),
289
- );
290
- seedLeadTable([
291
- lead({ id: "lead_own_1", payload: { name: "A".repeat(900_000) } }),
292
- ]);
293
-
294
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
295
-
296
- expect(result.ok).toBe(true);
297
- if (!result.ok) return;
298
- expect(result.value.items[0]?.name).toBe("A".repeat(200));
299
- expect(JSON.stringify(result.value).length).toBeLessThan(1000);
300
- });
301
-
302
- it("reads `name` as null when the site's form captured none", async () => {
303
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(
304
- membershipsWithRole("owner"),
305
- );
306
- seedLeadTable([lead({ id: "lead_own_1", payload: { company: "Acme" } })]);
307
-
308
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
309
-
310
- expect(result.ok).toBe(true);
311
- if (!result.ok) return;
312
- expect(result.value.items[0]?.name).toBeNull();
313
- });
314
-
315
- // ── Pagination ─────────────────────────────────────────────────────────────
316
- it("pages newest first and hands back the last row's id as nextCursor", async () => {
317
- (prisma.membership.findMany as jest.Mock).mockResolvedValue(
318
- membershipsWithRole("owner"),
319
- );
320
- seedLeadTable([
321
- lead({ id: "lead_a", createdAt: new Date("2026-09-15T10:00:00.000Z") }),
322
- lead({ id: "lead_b", createdAt: new Date("2026-09-15T11:00:00.000Z") }),
323
- lead({ id: "lead_c", createdAt: new Date("2026-09-15T12:00:00.000Z") }),
324
- ]);
325
-
326
- const first = await listOwnedLeads({ userId: USER_ID, limit: 2 });
327
- expect(first.ok).toBe(true);
328
- if (!first.ok) return;
329
- expect(first.value.items.map((row) => row.id)).toEqual(["lead_c", "lead_b"]);
330
- expect(first.value.nextCursor).toBe("lead_b");
331
-
332
- const second = await listOwnedLeads({
333
- userId: USER_ID,
334
- cursor: first.value.nextCursor ?? undefined,
335
- limit: 2,
336
- });
337
- expect(second.ok).toBe(true);
338
- if (!second.ok) return;
339
- expect(second.value.items.map((row) => row.id)).toEqual(["lead_a"]);
340
- expect(second.value.nextCursor).toBeNull();
341
- });
342
- });
343
-
344
- // ─── #6730 D7 — the single-tenant inbox, pinned to the operator ──────────────
345
-
346
- describe("listOwnedLeads — single-tenant build (#6730 D7)", () => {
347
- const { DEFAULT_ORG_ID, DEFAULT_WORKSPACE_ID } = jest.requireActual(
348
- "@working-theory/database/tenancy",
349
- ) as { DEFAULT_ORG_ID: string; DEFAULT_WORKSPACE_ID: string };
350
- const OPERATOR_EMAIL = "owner@example.com";
351
- const prior = {
352
- modules: process.env.AILK_ENABLED_MODULES,
353
- pin: process.env.AILK_OPERATOR_EMAIL,
354
- };
355
-
356
- /** One workspace — the seeded default — and a caller whose verified email is `email`. */
357
- function oneWorkspaceDeployment(opts: {
358
- email: string;
359
- verified?: boolean;
360
- workspaceCount?: number;
361
- role?: string;
362
- }) {
363
- (prisma.workspace.count as jest.Mock).mockResolvedValue(opts.workspaceCount ?? 1);
364
- (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
365
- id: DEFAULT_WORKSPACE_ID,
366
- organizationId: DEFAULT_ORG_ID,
367
- });
368
- (prisma.user.findUnique as jest.Mock).mockResolvedValue({
369
- id: USER_ID,
370
- email: opts.email,
371
- emailVerified: opts.verified ?? true,
372
- });
373
- (prisma.membership.create as jest.Mock).mockResolvedValue({});
374
- (prisma.membership.findUnique as jest.Mock).mockResolvedValue({
375
- role: opts.role ?? "owner",
376
- });
377
- }
378
-
379
- beforeEach(() => {
380
- jest.clearAllMocks();
381
- delete process.env.AILK_ENABLED_MODULES;
382
- process.env.AILK_OPERATOR_EMAIL = OPERATOR_EMAIL;
383
- seedLeadTable([
384
- lead({ id: "lead_own", siteId: DEFAULT_WORKSPACE_ID }),
385
- lead({ id: "lead_elsewhere", siteId: OTHER_SITE_ID }),
386
- ]);
387
- });
388
-
389
- afterAll(() => {
390
- for (const [key, value] of [
391
- ["AILK_ENABLED_MODULES", prior.modules],
392
- ["AILK_OPERATOR_EMAIL", prior.pin],
393
- ] as const) {
394
- if (value === undefined) delete process.env[key];
395
- else process.env[key] = value;
396
- }
397
- });
398
-
399
- it("the pinned, verified owner sees the default workspace's leads — and only those", async () => {
400
- oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
401
-
402
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
403
-
404
- expect(result.ok).toBe(true);
405
- if (!result.ok) return;
406
- expect(result.value.items.map((r) => r.id)).toEqual(["lead_own"]);
407
- });
408
-
409
- it("the pin unset → empty", async () => {
410
- delete process.env.AILK_OPERATOR_EMAIL;
411
- oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
412
-
413
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
414
-
415
- expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
416
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
417
- });
418
-
419
- it("a different verified email → empty", async () => {
420
- oneWorkspaceDeployment({ email: "stranger@example.com" });
421
-
422
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
423
-
424
- expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
425
- expect(prisma.lead.findMany).not.toHaveBeenCalled();
426
- });
427
-
428
- it("the pinned email, unverified → empty", async () => {
429
- oneWorkspaceDeployment({ email: OPERATOR_EMAIL, verified: false });
430
-
431
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
432
-
433
- expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
434
- });
435
-
436
- it("a second workspace → empty (not unambiguously one-workspace)", async () => {
437
- oneWorkspaceDeployment({ email: OPERATOR_EMAIL, workspaceCount: 2 });
438
-
439
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
440
-
441
- expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
442
- });
443
-
444
- it("a `member` role → the default workspace's leads — `inbox-read` holds every role", async () => {
445
- oneWorkspaceDeployment({ email: OPERATOR_EMAIL, role: "member" });
446
-
447
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
448
-
449
- expect(result.ok).toBe(true);
450
- if (!result.ok) return;
451
- expect(result.value.items.map((r) => r.id)).toEqual(["lead_own"]);
452
- });
453
-
454
- it("a role outside the permission table → empty — default deny", async () => {
455
- oneWorkspaceDeployment({ email: OPERATOR_EMAIL, role: "writer" });
456
-
457
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
458
-
459
- expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
460
- });
461
-
462
- it("with multi-tenant on, resolveOwnerWorkspaces is the path — the default-tenant resolver is not called", async () => {
463
- process.env.AILK_ENABLED_MODULES = "multi-tenant";
464
- oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
465
- (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
466
- const ownerWorkspaces = jest.requireActual("../owner-workspaces.js");
467
- const spy = jest.spyOn(ownerWorkspaces, "resolveOwnerWorkspaces");
468
-
469
- const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
470
-
471
- expect(spy).toHaveBeenCalledWith(USER_ID);
472
- expect(prisma.user.findUnique).not.toHaveBeenCalled();
473
- expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
474
- spy.mockRestore();
475
- });
476
- });