create-ailk 0.17.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 (193) 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/env-catalog.js +5 -9
  9. package/dist/lib/license-key-files.d.ts +6 -3
  10. package/dist/lib/license-key-files.js +8 -2
  11. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  12. package/dist/lib/scaffold-render-sweep.js +24 -3
  13. package/dist/license-set.d.ts +1 -1
  14. package/dist/license-set.js +13 -8
  15. package/dist/module-architecture.js +119 -0
  16. package/dist/programmatic.js +3 -0
  17. package/dist/spoke-checkout.d.ts +11 -3
  18. package/dist/spoke-checkout.js +62 -12
  19. package/dist/starter-grid.d.ts +1 -1
  20. package/dist/surfaces.js +140 -1
  21. package/dist/template-manifest.d.ts +4 -3
  22. package/dist/topology.d.ts +15 -2
  23. package/dist/topology.js +83 -4
  24. package/package.json +5 -4
  25. package/templates/.env.example +3 -3
  26. package/templates/.github/workflows/ci.yml +5 -0
  27. package/templates/apps/api/CLAUDE.md +25 -7
  28. package/templates/apps/api/LICENSE +6 -5
  29. package/templates/apps/api/NOTICE +2 -0
  30. package/templates/apps/api/jest.config.cjs +4 -29
  31. package/templates/apps/api/package.json +1 -0
  32. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  33. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  34. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  35. package/templates/apps/api/src/lib/stripe.ts +81 -0
  36. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  37. package/templates/apps/api/src/middleware/auth.ts +206 -46
  38. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  39. package/templates/apps/api/src/server.ts +107 -0
  40. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  41. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  42. package/templates/apps/api/vercel.json +2 -2
  43. package/templates/apps/mcp/.env.example +10 -0
  44. package/templates/apps/mcp/CLAUDE.md +58 -7
  45. package/templates/apps/mcp/LICENSE +6 -5
  46. package/templates/apps/mcp/NOTICE +2 -0
  47. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  48. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  49. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  50. package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
  51. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  52. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  53. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  54. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  55. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  56. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  57. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  58. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  59. package/templates/apps/mcp/jest.config.cjs +3 -44
  60. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  61. package/templates/apps/mcp/src/app.ts +32 -0
  62. package/templates/apps/mcp/src/boot.ts +167 -0
  63. package/templates/apps/mcp/src/http-transport.ts +132 -88
  64. package/templates/apps/mcp/src/index.ts +19 -126
  65. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  66. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  67. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  68. package/templates/apps/mcp/src/request-context.ts +52 -0
  69. package/templates/apps/mcp/src/server.ts +10 -2
  70. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  71. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  72. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  73. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  74. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  75. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  76. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  77. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  78. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  79. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  80. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  81. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  82. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  83. package/templates/apps/mcp/tsconfig.json +2 -0
  84. package/templates/apps/web/LICENSE +6 -5
  85. package/templates/apps/web/NOTICE +2 -0
  86. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  87. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  88. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  92. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  94. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  98. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  102. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  104. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
  105. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  125. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  126. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  127. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  132. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  135. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  138. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  139. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  140. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  141. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  142. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  143. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  144. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  145. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  146. package/templates/apps/web/auth.ts +5 -1
  147. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  148. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  149. package/templates/apps/web/lib/account-config.ts +188 -52
  150. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  151. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  152. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  153. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  154. package/templates/apps/web/lib/site-brand.tsx +22 -6
  155. package/templates/apps/web/lib/topology.generated.ts +27 -0
  156. package/templates/apps/web/messages/en.json +156 -35
  157. package/templates/database/CHANGELOG.md +21 -0
  158. package/templates/database/LICENSE +6 -5
  159. package/templates/database/NOTICE +2 -0
  160. package/templates/database/README.md +1 -1
  161. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  162. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  163. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  164. package/templates/database/__tests__/seed.test.ts +54 -2
  165. package/templates/database/inbox/schema.prisma +90 -15
  166. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  167. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  168. package/templates/database/package.json +3 -2
  169. package/templates/database/seed.ts +34 -2
  170. package/versions.json +36 -36
  171. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  172. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  173. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  174. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  175. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  176. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  177. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  178. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  179. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  180. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  182. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  183. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  184. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  185. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  186. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  187. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  188. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  189. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  190. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  191. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  192. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  193. 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
- });