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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- 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
|
-
});
|