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