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.
Files changed (146) hide show
  1. package/component-catalog.json +11939 -0
  2. package/component-catalog.md +176 -64
  3. package/dist/cli.js +29 -1
  4. package/dist/derive-architecture.js +5 -2
  5. package/dist/lib/extract-module-bundle.d.ts +50 -2
  6. package/dist/lib/extract-module-bundle.js +223 -15
  7. package/dist/lib/fetch-guard.d.ts +28 -0
  8. package/dist/lib/fetch-guard.js +101 -0
  9. package/dist/lib/fetch-module.js +4 -0
  10. package/dist/lib/fetch-template.js +4 -0
  11. package/dist/module-architecture.d.ts +59 -8
  12. package/dist/module-architecture.js +334 -115
  13. package/dist/parse-args.js +6 -2
  14. package/dist/programmatic.d.ts +10 -0
  15. package/dist/programmatic.js +134 -19
  16. package/dist/site-chrome.d.ts +80 -0
  17. package/dist/site-chrome.js +135 -0
  18. package/dist/surfaces.d.ts +21 -6
  19. package/dist/surfaces.js +107 -32
  20. package/dist/template-manifest.d.ts +55 -3
  21. package/dist/template-manifest.js +181 -12
  22. package/dist/template-names.d.ts +1 -1
  23. package/dist/template-names.js +1 -0
  24. package/package.json +9 -2
  25. package/site-templates/_base/ailk-template.json +6 -0
  26. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +331 -0
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +393 -0
  29. package/site-templates/marketing-site/ailk-template.json +11 -0
  30. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  31. package/site-templates/marketing-site/provenance.json +4 -0
  32. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  33. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +285 -0
  34. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  35. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +271 -0
  36. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +324 -0
  37. package/site-templates/minimalism-site/ailk-template.json +21 -0
  38. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  39. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  40. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  41. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  42. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  44. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  45. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  46. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  47. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  48. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  49. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  50. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  51. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +214 -0
  52. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  53. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  54. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  55. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  56. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  57. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  58. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  59. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  60. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  61. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  62. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  63. package/site-templates/minimalism-site/provenance.json +4 -0
  64. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  65. package/site-templates/saas-product/ailk-template.json +16 -0
  66. package/site-templates/saas-product/provenance.json +10 -0
  67. package/templates/.claude/skills/README.md +16 -25
  68. package/templates/.env.example +32 -7
  69. package/templates/.github/workflows/ci.yml +48 -0
  70. package/templates/README.md +3 -1
  71. package/templates/apps/api/CLAUDE.md +4 -1
  72. package/templates/apps/api/eslint.config.mjs +69 -0
  73. package/templates/apps/api/package.json +6 -2
  74. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +6 -2
  75. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  76. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +8 -1
  77. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  78. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +339 -0
  79. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +108 -0
  80. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  82. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  83. package/templates/apps/api/src/server.ts +82 -6
  84. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  85. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  86. package/templates/apps/api/src/services/default-tenant.ts +261 -0
  87. package/templates/apps/api/src/services/lead-promotion.ts +161 -0
  88. package/templates/apps/api/src/services/leads-inbox.ts +263 -0
  89. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  90. package/templates/apps/mcp/CLAUDE.md +1 -1
  91. package/templates/apps/mcp/__tests__/parity.test.ts +2 -2
  92. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  93. package/templates/apps/mcp/eslint.config.mjs +69 -0
  94. package/templates/apps/mcp/package.json +6 -2
  95. package/templates/apps/mcp/src/server.ts +2 -2
  96. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  97. package/templates/apps/mcp/src/tools/index.ts +2 -2
  98. package/templates/apps/web/.env.example +3 -3
  99. package/templates/apps/web/CLAUDE.md +2 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +4 -6
  101. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  103. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  104. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  105. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  106. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  107. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  108. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  109. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  111. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  112. package/templates/apps/web/app/[locale]/layout.tsx +214 -17
  113. package/templates/apps/web/components/google-analytics.tsx +18 -1
  114. package/templates/apps/web/eslint.config.mjs +69 -0
  115. package/templates/apps/web/jest.config.cjs +7 -15
  116. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +32 -5
  117. package/templates/apps/web/lib/nav-config.ts +27 -7
  118. package/templates/apps/web/lib/server-api.ts +1 -1
  119. package/templates/apps/web/lib/site-chrome.ts +29 -0
  120. package/templates/apps/web/lib/telemetry-scrub.ts +46 -0
  121. package/templates/apps/web/messages/en.json +29 -2
  122. package/templates/apps/web/next.config.mjs +20 -0
  123. package/templates/apps/web/package.json +6 -2
  124. package/templates/apps/web/sentry.client.config.ts +131 -0
  125. package/templates/apps/web/sentry.edge.config.ts +72 -0
  126. package/templates/apps/web/sentry.server.config.ts +72 -0
  127. package/templates/apps/web/src/lib/routes.ts +13 -18
  128. package/templates/brand_assets/README.md +24 -0
  129. package/templates/content/en/AboutPage/about.mdx +160 -15
  130. package/templates/content/en/ContactPage/contact.mdx +90 -13
  131. package/templates/content/en/FAQPage/faq.mdx +62 -22
  132. package/templates/content/en/HomePage/home.mdx +244 -20
  133. package/templates/content/en/LandingPage/blog.mdx +83 -0
  134. package/templates/content/en/LandingPage/features.mdx +103 -0
  135. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  136. package/templates/database/CHANGELOG.md +7 -0
  137. package/templates/database/__tests__/seed.test.ts +1 -1
  138. package/templates/database/eslint.config.mjs +69 -0
  139. package/templates/database/package.json +7 -3
  140. package/templates/database/seed.ts +1 -1
  141. package/templates/package.json +1 -14
  142. package/versions.json +52 -0
  143. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  144. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  145. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  146. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * @file default-tenant.test.ts
3
+ *
4
+ * Tenant-isolation coverage for the BASE-build tenant guard (#5677) —
5
+ * `middleware/default-tenant.ts`, the guard a build WITHOUT the multi-tenant
6
+ * module runs so the already-built review dashboard is reachable at Pro.
7
+ *
8
+ * SECURITY-SENSITIVE (tenant isolation). Two load-bearing groups here. The
9
+ * OPERATOR-PIN cases are the #5693 security-review remediation: on this build
10
+ * the review surface and `isPlatformOperator`'s unfiltered `{kind: "all"}`
11
+ * scope became reachable together, so the guard seats only the caller whose
12
+ * email matches `AILK_OPERATOR_EMAIL` and seats nobody when it is unset —
13
+ * including a caller who already holds the operator Membership. The FOREIGN
14
+ * WORKSPACE case is the other: a single-tenant caller naming another workspace's id in the
15
+ * URL is refused with the same 403 the paid slice returns, before any lookup
16
+ * of the target. This proves the base path did not relax the default-deny
17
+ * posture — it reproduces it. The multi-tenant slice's own isolation tests
18
+ * (`services/__tests__/tenant-context.test.ts`, #3631) are untouched and cover
19
+ * the module path.
20
+ *
21
+ * Mocks the module-level `prisma` singleton via the repo's manual-mock
22
+ * convention — `resolveDefaultTenant` reads the singleton directly and has no
23
+ * DI seam, the same shape `tenant-context.test.ts` uses.
24
+ */
25
+
26
+ import Fastify from "fastify";
27
+ import type { FastifyInstance } from "fastify";
28
+
29
+ // 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.
30
+ jest.mock("../../lib/prisma.js");
31
+
32
+ import { prisma } from "../../lib/prisma.js";
33
+ import type { Id } from "@working-theory/ids";
34
+
35
+ import { defaultTenantPreHandlerPlugin } from "../default-tenant.js";
36
+
37
+ // The reserved-range sentinel values from database/tenancy.ts (ADR-0031
38
+ // rule 7) — re-declared here as literals so the test pins the WIRE values,
39
+ // not whatever the constants happen to be.
40
+ const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
41
+ const DEFAULT_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
42
+
43
+ /** A workspace id belonging to somebody else — never this caller's. */
44
+ const FOREIGN_WORKSPACE_ID = "wksp_01936b2c-0000-7000-8000-00000000beef";
45
+
46
+ const USER_ID = "solo-operator" as Id<"user">;
47
+ const OPERATOR_EMAIL = "operator@example.com";
48
+
49
+ /**
50
+ * Stubs the single-tenant prerequisites `resolveDefaultTenant` checks:
51
+ * `workspace.count() === 1` and the sentinel default Workspace present on the
52
+ * sentinel default Organization.
53
+ */
54
+ function mockSingleTenantWorkspace(): void {
55
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(1);
56
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
57
+ id: DEFAULT_WORKSPACE_ID,
58
+ organizationId: DEFAULT_ORG_ID,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * The caller holds the operator Membership on the default Organization AND is
64
+ * the pinned operator — the only combination this guard grants on.
65
+ */
66
+ function mockBoundOperator(): void {
67
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
68
+ email: OPERATOR_EMAIL,
69
+ emailVerified: true,
70
+ });
71
+ (prisma.membership.create as jest.Mock).mockResolvedValue({});
72
+ (prisma.membership.findUnique as jest.Mock).mockResolvedValue({
73
+ role: "owner",
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Builds an app whose routes sit behind the base guard. `authenticated`
79
+ * mirrors what the dual-mode auth preHandler decorates upstream: a cookie
80
+ * session sets both `userId` and `sessionTenant`; a bearer request sets only
81
+ * `userId`.
82
+ */
83
+ function buildApp(
84
+ authenticated: "cookie" | "bearer" | "none" = "cookie",
85
+ ): FastifyInstance {
86
+ const app = Fastify();
87
+ app.addHook("preHandler", async (req) => {
88
+ if (authenticated === "none") return;
89
+ req.userId = USER_ID;
90
+ if (authenticated === "cookie") {
91
+ req.sessionTenant = {
92
+ sessionRowId: "session-1" as Id<"session">,
93
+ activeOrganizationId: null,
94
+ activeWorkspaceId: null,
95
+ };
96
+ }
97
+ });
98
+ app.register(defaultTenantPreHandlerPlugin);
99
+ app.get("/scoped", async (req) => ({
100
+ workspaceId: req.tenant?.workspaceId ?? null,
101
+ role: req.tenant?.role ?? null,
102
+ }));
103
+ app.get("/workspaces/:workspaceId/config", async (req) => ({
104
+ workspaceId: req.tenant?.workspaceId ?? null,
105
+ }));
106
+ return app;
107
+ }
108
+
109
+ const ORIGINAL_ENV = process.env;
110
+
111
+ beforeEach(() => {
112
+ jest.clearAllMocks();
113
+ process.env = { ...ORIGINAL_ENV };
114
+ // Pinned by default: this guard grants on no other configuration, so the
115
+ // cases below would all be trivially 403 without it.
116
+ process.env.AILK_OPERATOR_EMAIL = OPERATOR_EMAIL;
117
+ });
118
+
119
+ afterAll(() => {
120
+ process.env = ORIGINAL_ENV;
121
+ });
122
+
123
+ describe("default tenant guard — isolation (#5677)", () => {
124
+ it("DENIES a foreign workspaceId with 403, before any lookup of the target", async () => {
125
+ mockSingleTenantWorkspace();
126
+ mockBoundOperator();
127
+ const app = buildApp();
128
+
129
+ const res = await app.inject({
130
+ method: "GET",
131
+ url: `/workspaces/${FOREIGN_WORKSPACE_ID}/config`,
132
+ });
133
+
134
+ expect(res.statusCode).toBe(403);
135
+ expect(res.json()).toEqual({
136
+ ok: false,
137
+ error: { code: "forbidden", message: "Forbidden" },
138
+ });
139
+ // No enumeration: the foreign id is never handed to a lookup. The only
140
+ // workspace read is `resolveDefaultTenant`'s own by-sentinel-id read.
141
+ const workspaceReads = (prisma.workspace.findUnique as jest.Mock).mock.calls;
142
+ expect(workspaceReads).toHaveLength(1);
143
+ expect(workspaceReads[0]?.[0]?.where?.id).toBe(DEFAULT_WORKSPACE_ID);
144
+ await app.close();
145
+ });
146
+
147
+ it("GRANTS the caller's OWN workspaceId — the same URL shape, its own id", async () => {
148
+ mockSingleTenantWorkspace();
149
+ mockBoundOperator();
150
+ const app = buildApp();
151
+
152
+ const res = await app.inject({
153
+ method: "GET",
154
+ url: `/workspaces/${DEFAULT_WORKSPACE_ID}/config`,
155
+ });
156
+
157
+ expect(res.statusCode).toBe(200);
158
+ expect(res.json()).toEqual({ workspaceId: DEFAULT_WORKSPACE_ID });
159
+ await app.close();
160
+ });
161
+
162
+ it("resolves the SEEDED default workspace for an authenticated site owner — no tenant is synthesized", async () => {
163
+ mockSingleTenantWorkspace();
164
+ mockBoundOperator();
165
+ const app = buildApp();
166
+
167
+ const res = await app.inject({ method: "GET", url: "/scoped" });
168
+
169
+ expect(res.statusCode).toBe(200);
170
+ expect(res.json()).toEqual({
171
+ workspaceId: DEFAULT_WORKSPACE_ID,
172
+ role: "owner",
173
+ });
174
+ await app.close();
175
+ });
176
+
177
+ it("DENIES a caller holding no Membership on the default Organization (a second OAuth sign-in after TOFU)", async () => {
178
+ mockSingleTenantWorkspace();
179
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
180
+ email: OPERATOR_EMAIL,
181
+ emailVerified: true,
182
+ });
183
+ (prisma.membership.create as jest.Mock).mockRejectedValue({ code: "P2002" });
184
+ (prisma.membership.findUnique as jest.Mock).mockResolvedValue(null);
185
+ const app = buildApp();
186
+
187
+ const res = await app.inject({ method: "GET", url: "/scoped" });
188
+
189
+ expect(res.statusCode).toBe(403);
190
+ await app.close();
191
+ });
192
+
193
+ it("DENIES once a SECOND workspace exists — the guard never grants outside an unambiguously single-tenant deployment", async () => {
194
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(2);
195
+ mockBoundOperator();
196
+ const app = buildApp();
197
+
198
+ const res = await app.inject({ method: "GET", url: "/scoped" });
199
+
200
+ expect(res.statusCode).toBe(403);
201
+ expect(prisma.workspace.findUnique).not.toHaveBeenCalled();
202
+ await app.close();
203
+ });
204
+
205
+ it("DENIES when the sole workspace is NOT the well-known default", async () => {
206
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(1);
207
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue(null);
208
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
209
+ email: OPERATOR_EMAIL,
210
+ emailVerified: true,
211
+ });
212
+ const app = buildApp();
213
+
214
+ const res = await app.inject({ method: "GET", url: "/scoped" });
215
+
216
+ expect(res.statusCode).toBe(403);
217
+ await app.close();
218
+ });
219
+
220
+ it("DENIES when the sole default workspace hangs off a NON-sentinel organization", async () => {
221
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(1);
222
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
223
+ id: DEFAULT_WORKSPACE_ID,
224
+ organizationId: "org_01936b2c-0000-7000-8000-00000000beef",
225
+ });
226
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
227
+ email: OPERATOR_EMAIL,
228
+ emailVerified: true,
229
+ });
230
+ const app = buildApp();
231
+
232
+ const res = await app.inject({ method: "GET", url: "/scoped" });
233
+
234
+ expect(res.statusCode).toBe(403);
235
+ expect(prisma.membership.create).not.toHaveBeenCalled();
236
+ await app.close();
237
+ });
238
+
239
+ it("DENIES a bearer-authenticated caller with 403 — no workspace binding on an agent token (#3591)", async () => {
240
+ mockSingleTenantWorkspace();
241
+ mockBoundOperator();
242
+ const app = buildApp("bearer");
243
+
244
+ const res = await app.inject({ method: "GET", url: "/scoped" });
245
+
246
+ expect(res.statusCode).toBe(403);
247
+ expect(prisma.workspace.count).not.toHaveBeenCalled();
248
+ await app.close();
249
+ });
250
+
251
+ it("DENIES when AILK_OPERATOR_EMAIL is UNSET, even for a caller holding the owner Membership — an unpinned deployment seats nobody (#5693 security review)", async () => {
252
+ delete process.env.AILK_OPERATOR_EMAIL;
253
+ mockSingleTenantWorkspace();
254
+ mockBoundOperator();
255
+ const app = buildApp();
256
+
257
+ const res = await app.inject({ method: "GET", url: "/scoped" });
258
+
259
+ expect(res.statusCode).toBe(403);
260
+ // Fails closed BEFORE the TOFU bind: an unpinned deployment must not seat
261
+ // whoever authenticates first, because `isPlatformOperator` would later
262
+ // promote exactly that identity to an unfiltered read.
263
+ expect(prisma.membership.create).not.toHaveBeenCalled();
264
+ expect(prisma.workspace.count).not.toHaveBeenCalled();
265
+ await app.close();
266
+ });
267
+
268
+ it("DENIES a caller who ALREADY HOLDS the owner Membership but is not the pinned operator — a squatter bound before the pin existed stays denied (#5693 security review)", async () => {
269
+ mockSingleTenantWorkspace();
270
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
271
+ email: "squatter@evil.example",
272
+ emailVerified: true,
273
+ });
274
+ (prisma.membership.create as jest.Mock).mockResolvedValue({});
275
+ (prisma.membership.findUnique as jest.Mock).mockResolvedValue({
276
+ role: "owner",
277
+ });
278
+ const app = buildApp();
279
+
280
+ const res = await app.inject({ method: "GET", url: "/scoped" });
281
+
282
+ expect(res.statusCode).toBe(403);
283
+ // The Membership row is not enough — the email is re-checked per request,
284
+ // so a pre-existing bind cannot be inherited by adding the pin later.
285
+ expect(prisma.workspace.count).not.toHaveBeenCalled();
286
+ await app.close();
287
+ });
288
+
289
+ it("GRANTS the pinned operator case-insensitively, with surrounding whitespace trimmed", async () => {
290
+ process.env.AILK_OPERATOR_EMAIL = " OPERATOR@Example.COM ";
291
+ mockSingleTenantWorkspace();
292
+ mockBoundOperator();
293
+ const app = buildApp();
294
+
295
+ const res = await app.inject({ method: "GET", url: "/scoped" });
296
+
297
+ expect(res.statusCode).toBe(200);
298
+ expect(res.json()).toEqual({
299
+ workspaceId: DEFAULT_WORKSPACE_ID,
300
+ role: "owner",
301
+ });
302
+ await app.close();
303
+ });
304
+
305
+ it("DENIES the pinned operator whose email is UNVERIFIED — an unverified address is an unproven claim to the pin", async () => {
306
+ mockSingleTenantWorkspace();
307
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
308
+ email: OPERATOR_EMAIL,
309
+ emailVerified: false,
310
+ });
311
+ const app = buildApp();
312
+
313
+ const res = await app.inject({ method: "GET", url: "/scoped" });
314
+
315
+ expect(res.statusCode).toBe(403);
316
+ expect(prisma.workspace.count).not.toHaveBeenCalled();
317
+ await app.close();
318
+ });
319
+
320
+ it("DENIES when the pinned caller has no User row", async () => {
321
+ mockSingleTenantWorkspace();
322
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
323
+ const app = buildApp();
324
+
325
+ const res = await app.inject({ method: "GET", url: "/scoped" });
326
+
327
+ expect(res.statusCode).toBe(403);
328
+ await app.close();
329
+ });
330
+
331
+ it("returns 401 for an unauthenticated caller", async () => {
332
+ const app = buildApp("none");
333
+
334
+ const res = await app.inject({ method: "GET", url: "/scoped" });
335
+
336
+ expect(res.statusCode).toBe(401);
337
+ await app.close();
338
+ });
339
+ });
@@ -377,3 +377,111 @@ describe("per-IP rate limit middleware", () => {
377
377
  expect(DEFAULT_PER_IP_PER_MINUTE_STRICT).toBe(10);
378
378
  });
379
379
  });
380
+
381
+ /**
382
+ * Cross-isolate demonstration (#5040 AC-1).
383
+ *
384
+ * The middleware's `buckets` store is module-scoped (see the module
385
+ * docstring's SINGLE-REPLICA CAVEAT) — it is per-process state, not a
386
+ * shared external store. On the real deployment target this means: each
387
+ * serverless isolate that cold-loads this module gets its own empty Map,
388
+ * so the same visitor IP is tracked independently per isolate rather than
389
+ * against one shared counter.
390
+ *
391
+ * This process cannot spin up two real Vercel isolates (no live target —
392
+ * see #5040's issue comment + docs/specs/5111-limiter-surface-spec.md §8
393
+ * for the exact probe attempted and why it could not reach one as of
394
+ * 2026-09-15). What IS fully within this process's control is the root
395
+ * mechanism itself: module-scoped state does not survive a fresh module
396
+ * load. `jest.isolateModules` gives each block below its own Jest module
397
+ * registry, so requiring the middleware module inside each returns a
398
+ * DISTINCT module instance with its own private `buckets` Map — the same
399
+ * state-isolation shape a serverless cold start produces (a fresh module
400
+ * load with an empty Map), reproduced deterministically in-process.
401
+ *
402
+ * This demonstrates — rather than merely asserts, per AC-1's own wording —
403
+ * that the per-IP limit, AS CURRENTLY DESIGNED, does NOT hold across
404
+ * separate execution contexts: the same IP, already rate-limited to
405
+ * exhaustion on one instance, is granted a fresh budget on another. This
406
+ * is the accepted, documented trade-off recorded in
407
+ * docs/specs/5111-limiter-surface-spec.md § D4 (no new store for OSS v1;
408
+ * MAX_BUCKETS bounds memory, not the cross-isolate aggregate rate) — this
409
+ * test makes that trade-off provable rather than only claimed in a
410
+ * comment, and will fail (correctly) the day a cross-isolate store
411
+ * replaces the in-process Map.
412
+ *
413
+ * What this test does NOT demonstrate: whether the LIVE deployment
414
+ * actually runs more than one concurrent isolate for a given route today.
415
+ * That is a platform fact only a reachable live deployment can answer
416
+ * (#5040 AC-1's deployment-level half, recorded open in the issue).
417
+ */
418
+ describe("cross-isolate limit — does NOT hold by design (#5040, SINGLE-REPLICA CAVEAT)", () => {
419
+ it("the same IP, exhausted on one module instance, is freshly allowed on an independently-loaded instance", async () => {
420
+ const perIpPerMinute = 5;
421
+ const ip = "198.51.100.7";
422
+
423
+ let isolateAppA: ReturnType<typeof buildApp> | undefined;
424
+ let isolateAppB: ReturnType<typeof buildApp> | undefined;
425
+
426
+ // Each isolateModules block gets a fresh require cache: requiring the
427
+ // middleware module inside it yields a module instance distinct from
428
+ // the top-level `rateLimitMiddleware` import used by every other test
429
+ // in this file (and distinct from each other).
430
+ jest.isolateModules(() => {
431
+ const isolateA: typeof import("../rate-limit") = require("../rate-limit");
432
+ const app = Fastify({ trustProxy: true });
433
+ app.register(isolateA.rateLimitMiddleware, { perIpPerMinute });
434
+ app.get("/test", async () => ({ ok: true }));
435
+ isolateAppA = app;
436
+ });
437
+
438
+ jest.isolateModules(() => {
439
+ const isolateB: typeof import("../rate-limit") = require("../rate-limit");
440
+ const app = Fastify({ trustProxy: true });
441
+ app.register(isolateB.rateLimitMiddleware, { perIpPerMinute });
442
+ app.get("/test", async () => ({ ok: true }));
443
+ isolateAppB = app;
444
+ });
445
+
446
+ if (!isolateAppA || !isolateAppB) {
447
+ throw new Error("isolateModules did not synchronously assign both apps");
448
+ }
449
+
450
+ await isolateAppA.ready();
451
+ await isolateAppB.ready();
452
+
453
+ try {
454
+ // Exhaust isolate A's per-IP budget entirely.
455
+ for (let i = 0; i < perIpPerMinute; i++) {
456
+ const res = await isolateAppA.inject({
457
+ method: "GET",
458
+ url: "/test",
459
+ headers: { "x-forwarded-for": ip },
460
+ });
461
+ expect(res.statusCode).toBe(200);
462
+ }
463
+ const exhaustedOnA = await isolateAppA.inject({
464
+ method: "GET",
465
+ url: "/test",
466
+ headers: { "x-forwarded-for": ip },
467
+ });
468
+ expect(exhaustedOnA.statusCode).toBe(429);
469
+
470
+ // The SAME ip, already 429'd on A, hits an independently-loaded
471
+ // instance (B) for the first time — B's own buckets Map has never
472
+ // seen this IP, so it is granted a full fresh budget rather than
473
+ // being held to the aggregate limit across A+B. This is the
474
+ // cross-isolate gap: (perIpPerMinute × isolate count) requests
475
+ // succeed in aggregate for one IP, not perIpPerMinute.
476
+ const firstOnB = await isolateAppB.inject({
477
+ method: "GET",
478
+ url: "/test",
479
+ headers: { "x-forwarded-for": ip },
480
+ });
481
+ expect(firstOnB.statusCode).toBe(200);
482
+ } finally {
483
+ await isolateAppA.close();
484
+ await isolateAppB.close();
485
+ }
486
+ });
487
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Default-tenant guard preHandler — the BASE-build tenant guard (#5677).
3
+ *
4
+ * The base-build counterpart to `./tenant.ts`, which is a multi-tenant SLICE
5
+ * (`// oss: false`) and therefore absent on a build without that module. Same
6
+ * posture, same decoration, same denials; the only difference is where the
7
+ * tenant comes from — the seeded default Workspace
8
+ * (`services/default-tenant.ts#resolveDefaultTenant`) rather than the
9
+ * session's active workspace, which a single-tenant site does not have.
10
+ *
11
+ * `server.ts` picks ONE of the two per build: multi-tenant's guard when the
12
+ * module is enabled, this one otherwise. They never both run, and this one is
13
+ * never reachable on a build that has the module — a fallback beneath it, not
14
+ * a parallel path.
15
+ *
16
+ * Default-deny — every branch that is not an explicit grant is a denial:
17
+ *
18
+ * - unauthenticated → 401
19
+ * - bearer-authenticated (no cookie session — #3591) → 403
20
+ * - AILK_OPERATOR_EMAIL is unset → 403
21
+ * - the caller's email is not the pinned one → 403
22
+ * - the deployment is not unambiguously single-tenant → 403
23
+ * - the caller holds no Membership on the default Org → 403
24
+ * - URL workspaceId ≠ the resolved workspace → 403
25
+ * (compared BEFORE any lookup of the target — no tenant enumeration)
26
+ *
27
+ * The two `AILK_OPERATOR_EMAIL` branches are this guard's own, and they are
28
+ * why it calls `resolveDefaultTenantForOperator` rather than
29
+ * `resolveDefaultTenant`: on this build the review surface and
30
+ * `isPlatformOperator`'s unfiltered `{kind: "all"}` scope became reachable
31
+ * together, so an unpinned deployment must seat nobody rather than seat
32
+ * whoever authenticates first. See that function for the full reasoning. A
33
+ * deployment that wants the dashboard MUST pin the operator; unset is a
34
+ * refusal, not a default-open.
35
+ *
36
+ * The URL-workspaceId comparison is not redundant on this path even though
37
+ * `resolveDefaultTenant` only ever yields the one seeded workspace: it is what
38
+ * makes a request naming a FOREIGN workspaceId a 403 rather than a silently
39
+ * mis-scoped read, and it is the same comparison `tenant.ts` makes, in the
40
+ * same place.
41
+ *
42
+ * This guard deliberately does NOT read `sessionTenant.activeWorkspaceId`.
43
+ * `resolveDefaultTenant` grants only while `workspace.count() === 1` and that
44
+ * sole workspace is the sentinel default, so on any build this path can grant
45
+ * on there is no second workspace for a stale session binding to point at.
46
+ *
47
+ * On grant it decorates `request.tenant` (a `TenantContext`), exactly as
48
+ * `tenant.ts` does; route handlers pass that to `tenantDb(tenant)` so every
49
+ * domain query is workspace-scoped. The `declare module` augmentation below is
50
+ * the ONE copy of that decoration's type — `tenant.ts` no longer carries its
51
+ * own, because this file ships on every build and the paid slice does not.
52
+ */
53
+ import type {
54
+ FastifyPluginCallback,
55
+ FastifyReply,
56
+ FastifyRequest,
57
+ } from "fastify";
58
+
59
+ import {
60
+ resolveDefaultTenantForOperator,
61
+ type TenantContext,
62
+ } from "../services/default-tenant.js";
63
+
64
+ declare module "fastify" {
65
+ interface FastifyRequest {
66
+ tenant?: TenantContext;
67
+ }
68
+ }
69
+
70
+ const FORBIDDEN = {
71
+ ok: false,
72
+ error: { code: "forbidden", message: "Forbidden" },
73
+ } as const;
74
+
75
+ /**
76
+ * Resolve + attach the default tenant, or reply 401/403. Registered as a hook
77
+ * in a tenant route scope (after auth), so no tenant-scoped handler runs
78
+ * without a granted TenantContext.
79
+ */
80
+ export async function defaultTenantPreHandler(
81
+ request: FastifyRequest,
82
+ reply: FastifyReply,
83
+ ): Promise<void> {
84
+ const userId = request.userId;
85
+ if (!userId) {
86
+ reply.code(401).send({
87
+ ok: false,
88
+ error: { code: "unauthorized", message: "Authentication required" },
89
+ });
90
+ return;
91
+ }
92
+
93
+ // Bearer tokens carry no workspace binding (#3591) — denied here, the same
94
+ // default-deny `resolveActiveTenant` applies on the multi-tenant path.
95
+ if (!request.sessionTenant) {
96
+ reply.code(403).send(FORBIDDEN);
97
+ return;
98
+ }
99
+
100
+ const tenant = await resolveDefaultTenantForOperator(userId);
101
+ if (!tenant) {
102
+ reply.code(403).send(FORBIDDEN);
103
+ return;
104
+ }
105
+
106
+ // URL-scoped routes: the target workspace MUST be the resolved workspace.
107
+ // Compared before any target lookup — a foreign (or nonexistent)
108
+ // workspaceId gets the same 403.
109
+ const params = request.params as { workspaceId?: string } | undefined;
110
+ if (params?.workspaceId && params.workspaceId !== tenant.workspaceId) {
111
+ reply.code(403).send(FORBIDDEN);
112
+ return;
113
+ }
114
+
115
+ request.tenant = tenant;
116
+ }
117
+
118
+ /**
119
+ * Plugin form, mirroring `tenantPreHandlerPlugin`: skip-override so the hook
120
+ * propagates to sibling route plugins registered in the same scope.
121
+ */
122
+ export const defaultTenantPreHandlerPlugin: FastifyPluginCallback = (
123
+ fastify,
124
+ _opts,
125
+ done,
126
+ ) => {
127
+ fastify.decorateRequest("tenant", undefined);
128
+ fastify.addHook("preHandler", defaultTenantPreHandler);
129
+ done();
130
+ };
131
+
132
+ (
133
+ defaultTenantPreHandlerPlugin as FastifyPluginCallback & {
134
+ [key: symbol]: boolean;
135
+ }
136
+ )[Symbol.for("skip-override")] = true;