create-ailk 0.11.2 → 0.11.3

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 (76) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/surfaces.d.ts +4 -5
  4. package/dist/surfaces.js +50 -8
  5. package/package.json +3 -3
  6. package/templates/.env.example +29 -0
  7. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  8. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  9. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  10. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  11. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  12. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  13. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  14. package/templates/apps/api/src/server.ts +28 -0
  15. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  16. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  17. package/templates/apps/api/src/vercel-handler.ts +58 -12
  18. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  19. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  20. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  21. package/templates/apps/web/.env.example +4 -0
  22. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  23. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  24. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  25. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  26. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  27. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  28. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  29. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  30. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  31. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  32. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  35. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  36. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  37. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  38. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  39. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  40. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  43. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  45. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  46. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  47. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  49. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  50. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  51. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  52. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  53. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  54. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  55. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  56. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  57. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  58. package/templates/apps/web/app/[locale]/layout.tsx +41 -28
  59. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  60. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  61. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  62. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  63. package/templates/apps/web/auth.ts +29 -5
  64. package/templates/apps/web/jest.config.cjs +5 -0
  65. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  66. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  67. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  68. package/templates/apps/web/lib/account-config.ts +219 -11
  69. package/templates/apps/web/lib/auth-email.ts +104 -2
  70. package/templates/apps/web/lib/auth-env.ts +37 -5
  71. package/templates/apps/web/lib/auth-routes.ts +33 -4
  72. package/templates/apps/web/messages/en.json +147 -21
  73. package/templates/database/CHANGELOG.md +7 -0
  74. package/templates/database/package.json +1 -1
  75. package/versions.json +33 -33
  76. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -32,7 +32,9 @@ import { authPreHandlerPlugin } from "./middleware/auth.js";
32
32
  import { defaultTenantPreHandlerPlugin } from "./middleware/default-tenant.js";
33
33
  import {
34
34
  rateLimitMiddleware,
35
+ buildTrustedKeyDigests,
35
36
  DEFAULT_PER_IP_PER_MINUTE_STRICT,
37
+ resolveRateLimitIdentity,
36
38
  } from "./middleware/rate-limit.js";
37
39
  import { openapiRoutes } from "./openapi/index.js";
38
40
  import {
@@ -615,6 +617,17 @@ export async function build() {
615
617
  trustProxy: true,
616
618
  });
617
619
 
620
+ // The hub's forwarding-key digests (#7394 DD-2), parsed once from
621
+ // AILK_VISITOR_FORWARD_KEYS and shared by the global ceiling and the leads
622
+ // scope below, so both limiters apply one identity rule. A keyed spoke's
623
+ // lead write is counted per visitor (`x-ailk-visitor-ip`); everything else
624
+ // keys on `req.ip`. Unset → `[]`, which believes no forwarded visitor. The
625
+ // parse warns by entry position only, never an entry's value.
626
+ const visitorForwardKeyDigests = buildTrustedKeyDigests(
627
+ process.env.AILK_VISITOR_FORWARD_KEYS,
628
+ (msg) => server.log.warn(msg),
629
+ );
630
+
618
631
  // Collect the route table this server registers (#5314).
619
632
  //
620
633
  // Installed here — after `Fastify()` and before the first `register()` — so
@@ -688,9 +701,16 @@ export async function build() {
688
701
  // limiter is in-memory by default, which suits a single Vercel function /
689
702
  // single-process dev server; multi-instance deployments will need a
690
703
  // Redis store, swappable here without changing route code.
704
+ //
705
+ // `keyGenerator` (#7394 DD-7): a keyed spoke's lead write counts against its
706
+ // visitor's own ceiling here too, or a spoke whose visitors share one
707
+ // egress IP would still be capped at this limit in total. For every other
708
+ // request it returns `req.ip`, the plugin's own default key.
691
709
  await server.register(rateLimit, {
692
710
  max: CONTENT_RATE_LIMIT_UNAUTH,
693
711
  timeWindow: CONTENT_RATE_LIMIT_WINDOW,
712
+ keyGenerator: (req) =>
713
+ resolveRateLimitIdentity(req, visitorForwardKeyDigests).identity,
694
714
  });
695
715
 
696
716
  // Health check
@@ -1189,9 +1209,17 @@ export async function build() {
1189
1209
  // default (10/min, pre-#4730) rather than inheriting the wider
1190
1210
  // flows-sized default (#5111, DEF-004 — #4730's shared-default bump was
1191
1211
  // never meant to loosen abuse protection here).
1212
+ //
1213
+ // `scope: "leads"` (#7394): this scope's buckets are its own, not shared
1214
+ // with the other scopes' bare-IP count, and AILK_RATE_LIMIT_LEADS_PER_MINUTE
1215
+ // (and ..._PER_KEY_PER_MINUTE, default 300) override the code values. With
1216
+ // `trustedKeyDigests`, a keyed spoke's three lead writes are counted per
1217
+ // forwarded visitor; the project-listing routes below still key on `req.ip`.
1192
1218
  await server.register(async (leadsScope) => {
1193
1219
  leadsScope.register(rateLimitMiddleware, {
1194
1220
  perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1221
+ scope: "leads",
1222
+ trustedKeyDigests: visitorForwardKeyDigests,
1195
1223
  });
1196
1224
  await leadsScope.register(leadSubmitRoute);
1197
1225
  await leadsScope.register(leadSubscribeRoute);
@@ -0,0 +1,251 @@
1
+ // oss: false
2
+ /**
3
+ * @file lead-accept-user.test.ts
4
+ *
5
+ * The find-or-create behind accepting an inbox message (#7418 D1, D2, D7, D7a,
6
+ * D8): the lead's email resolves to ONE `auth.User` — an existing one is
7
+ * linked and left untouched, a missing one is created verified with the plain
8
+ * `user` role and no credential, membership or session.
9
+ *
10
+ * External dependency boundary mocked: `lib/prisma` — an inline factory with
11
+ * an in-memory `users` table that honours the case-insensitive `equals`
12
+ * filter and the byte-exact `@unique` email constraint (a duplicate create
13
+ * throws Prisma's `P2002`). `account`/`session`/`membership` carry `create`
14
+ * spies so AC-3 can assert none of them is ever written.
15
+ */
16
+
17
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
18
+ jest.mock("../../lib/prisma.js", () => ({
19
+ prisma: {
20
+ user: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
21
+ account: { create: jest.fn() },
22
+ session: { create: jest.fn() },
23
+ membership: { create: jest.fn() },
24
+ },
25
+ }));
26
+
27
+ import { prisma } from "../../lib/prisma.js";
28
+ import { findOrCreateUserForLeadEmail } from "../lead-accept-user";
29
+
30
+ type UserRow = {
31
+ id: string;
32
+ email: string;
33
+ name: string | null;
34
+ emailVerified: boolean;
35
+ role: string;
36
+ createdAt: Date;
37
+ };
38
+
39
+ type UserWhere = { email?: { equals: string; mode?: "insensitive" } };
40
+
41
+ let USERS: UserRow[] = [];
42
+ let nextUser = 1;
43
+
44
+ function userId(n: number): string {
45
+ return `user_00000000-0000-7000-8000-${n.toString(16).padStart(12, "0")}`;
46
+ }
47
+
48
+ function uniqueViolation(): Error {
49
+ return Object.assign(new Error("Unique constraint failed on the fields: (`email`)"), {
50
+ code: "P2002",
51
+ });
52
+ }
53
+
54
+ /** Wires `prisma.user.findFirst`/`create` to a fresh in-memory table. */
55
+ function seedUsers(rows: UserRow[]): UserRow[] {
56
+ USERS = rows.map((r) => ({ ...r }));
57
+ nextUser = 100;
58
+
59
+ (prisma.user.findFirst as jest.Mock).mockImplementation(
60
+ async ({ where }: { where: UserWhere }) => {
61
+ const filter = where.email;
62
+ const match = USERS.filter((u) =>
63
+ filter?.mode === "insensitive"
64
+ ? u.email.toLowerCase() === filter.equals.toLowerCase()
65
+ : u.email === filter?.equals,
66
+ ).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())[0];
67
+ return match ? { id: match.id, emailVerified: match.emailVerified } : null;
68
+ },
69
+ );
70
+ (prisma.user.create as jest.Mock).mockImplementation(
71
+ async ({ data }: { data: Partial<UserRow> & { email: string } }) => {
72
+ // Postgres `@unique` is byte-exact — the same constraint the real
73
+ // table carries.
74
+ if (USERS.some((u) => u.email === data.email)) throw uniqueViolation();
75
+ const row: UserRow = {
76
+ id: userId(nextUser++),
77
+ name: null,
78
+ emailVerified: false,
79
+ role: "user",
80
+ createdAt: new Date(),
81
+ ...data,
82
+ };
83
+ USERS.push(row);
84
+ return { id: row.id };
85
+ },
86
+ );
87
+ return USERS;
88
+ }
89
+
90
+ function existingUser(overrides: Partial<UserRow> & { email: string }): UserRow {
91
+ return {
92
+ id: userId(1),
93
+ name: "Existing Person",
94
+ emailVerified: false,
95
+ role: "admin",
96
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ describe("findOrCreateUserForLeadEmail (#7418)", () => {
102
+ beforeEach(() => {
103
+ jest.clearAllMocks();
104
+ });
105
+
106
+ it("AC-3 / D1 — with no existing user, creates exactly one verified plain user with the lowercased email and no name, and no Account/Session/Membership", async () => {
107
+ const table = seedUsers([]);
108
+
109
+ const result = await findOrCreateUserForLeadEmail({
110
+ email: " New.Person@Example.COM ",
111
+ });
112
+
113
+ expect(result).toEqual({
114
+ ok: true,
115
+ value: { userId: table[0]?.id, created: true },
116
+ });
117
+ expect(table).toHaveLength(1);
118
+ expect(table[0]).toMatchObject({
119
+ email: "new.person@example.com",
120
+ emailVerified: true,
121
+ role: "user",
122
+ name: null,
123
+ });
124
+ expect(prisma.user.create).toHaveBeenCalledTimes(1);
125
+ const [{ data }] = (prisma.user.create as jest.Mock).mock.calls[0] as [
126
+ { data: Record<string, unknown> },
127
+ ];
128
+ // D7a — no name copied from the anonymous payload; nothing nested that
129
+ // would create a relation row.
130
+ expect(Object.keys(data).sort()).toEqual(["email", "emailVerified", "role"]);
131
+ expect(prisma.account.create).not.toHaveBeenCalled();
132
+ expect(prisma.session.create).not.toHaveBeenCalled();
133
+ expect(prisma.membership.create).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("AC-4 / D2 — with an existing user, links to it and changes none of its fields", async () => {
137
+ const before = existingUser({ email: "ada@example.com" });
138
+ const table = seedUsers([before]);
139
+
140
+ const result = await findOrCreateUserForLeadEmail({ email: "ada@example.com" });
141
+
142
+ expect(result).toEqual({
143
+ ok: true,
144
+ value: { userId: before.id, created: false },
145
+ });
146
+ expect(table).toEqual([before]);
147
+ expect(prisma.user.create).not.toHaveBeenCalled();
148
+ expect(prisma.user.update).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it("AC-10 / D7 — an existing `Foo@Example.com` is linked from a lead email `foo@example.com`; no user is created", async () => {
152
+ const before = existingUser({ email: "Foo@Example.com" });
153
+ const table = seedUsers([before]);
154
+
155
+ const result = await findOrCreateUserForLeadEmail({ email: "foo@example.com" });
156
+
157
+ expect(result).toEqual({
158
+ ok: true,
159
+ value: { userId: before.id, created: false },
160
+ });
161
+ expect(table).toHaveLength(1);
162
+ expect(prisma.user.create).not.toHaveBeenCalled();
163
+ expect(prisma.user.findFirst).toHaveBeenCalledWith(
164
+ expect.objectContaining({
165
+ where: { email: { equals: "foo@example.com", mode: "insensitive" } },
166
+ orderBy: { createdAt: "asc" },
167
+ }),
168
+ );
169
+ });
170
+
171
+ it("D7 — two case-variant rows for one mailbox link the OLDEST, with no distinct error", async () => {
172
+ const older = existingUser({
173
+ id: userId(2),
174
+ email: "foo@example.com",
175
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
176
+ });
177
+ const newer = existingUser({
178
+ id: userId(3),
179
+ email: "FOO@example.com",
180
+ createdAt: new Date("2026-02-01T00:00:00.000Z"),
181
+ });
182
+ seedUsers([newer, older]);
183
+
184
+ const result = await findOrCreateUserForLeadEmail({ email: "Foo@Example.com" });
185
+
186
+ expect(result).toEqual({
187
+ ok: true,
188
+ value: { userId: older.id, created: false },
189
+ });
190
+ });
191
+
192
+ it.each(["", " ", "not-an-email", "ada@", "@example.com"])(
193
+ "D7 — a lead email that is not an address after trimming (%j) is invalid_email, and nothing is read or written",
194
+ async (email) => {
195
+ seedUsers([]);
196
+
197
+ const result = await findOrCreateUserForLeadEmail({ email });
198
+
199
+ expect(result).toEqual({ ok: false, error: { kind: "invalid_email" } });
200
+ expect(prisma.user.findFirst).not.toHaveBeenCalled();
201
+ expect(prisma.user.create).not.toHaveBeenCalled();
202
+ },
203
+ );
204
+
205
+ it("D8 — a create that loses a race (P2002) re-reads and links the winner with created: false", async () => {
206
+ const table = seedUsers([]);
207
+ // The concurrent winner lands between this call's read and its create.
208
+ (prisma.user.findFirst as jest.Mock).mockImplementationOnce(async () => {
209
+ table.push(existingUser({ id: userId(9), email: "race@example.com" }));
210
+ return null;
211
+ });
212
+
213
+ const result = await findOrCreateUserForLeadEmail({ email: "race@example.com" });
214
+
215
+ expect(result).toEqual({
216
+ ok: true,
217
+ value: { userId: userId(9), created: false },
218
+ });
219
+ expect(table).toHaveLength(1);
220
+ expect(prisma.user.create).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+ it("AC-8 — two concurrent calls for one email leave exactly one user, and both resolve to it", async () => {
224
+ const table = seedUsers([]);
225
+
226
+ const [a, b] = await Promise.all([
227
+ findOrCreateUserForLeadEmail({ email: "twice@example.com" }),
228
+ findOrCreateUserForLeadEmail({ email: "Twice@Example.com" }),
229
+ ]);
230
+
231
+ expect(table).toHaveLength(1);
232
+ // Both reached the create; the second hit P2002 and re-read (D8).
233
+ expect(prisma.user.create).toHaveBeenCalledTimes(2);
234
+ const id = table[0]?.id;
235
+ expect(a).toMatchObject({ ok: true, value: { userId: id } });
236
+ expect(b).toMatchObject({ ok: true, value: { userId: id } });
237
+ expect([a, b].filter((r) => r.ok && r.value.created)).toHaveLength(1);
238
+ });
239
+
240
+ it("SEC-006 — a Prisma failure is internal_error and never carries the Prisma message", async () => {
241
+ seedUsers([]);
242
+ (prisma.user.create as jest.Mock).mockRejectedValueOnce(
243
+ new Error("connection to db.internal:5432 failed for ada@example.com"),
244
+ );
245
+
246
+ const result = await findOrCreateUserForLeadEmail({ email: "ada@example.com" });
247
+
248
+ expect(result).toEqual({ ok: false, error: { kind: "internal_error" } });
249
+ expect(JSON.stringify(result)).not.toContain("ada@example.com");
250
+ });
251
+ });
@@ -0,0 +1,126 @@
1
+ // oss: false
2
+ /**
3
+ * @file lead-accept-user.ts
4
+ * @description The `auth.User` behind an accepted inbox message (#7418) —
5
+ * find the user whose email matches the lead's, or create one, so the person
6
+ * who wrote in can later sign in with that same address.
7
+ *
8
+ * ## What a created user is (D1, D7a)
9
+ *
10
+ * `emailVerified: true`, `role: "user"`, the lowercased address, no `name` —
11
+ * and NOTHING else: no `Account` (so no password and no linked provider), no
12
+ * `Session`, no `Membership`. Verified is the operator's ruling (2026-10-01),
13
+ * and it is safe only because the row holds no credential: reaching a session
14
+ * still needs the address owner to prove control of it (Google, which
15
+ * reports a verified email, or the password-reset mail). It is also what
16
+ * Better Auth needs to link that Google login at all — it refuses to link an
17
+ * OAuth account to an existing local user whose `emailVerified` is false.
18
+ * No name is copied: the lead payload is anonymous, unbounded text, and
19
+ * `User.name` is rendered elsewhere.
20
+ *
21
+ * ## What an existing user gets (D2)
22
+ *
23
+ * Nothing. It is linked by id and never written — no `emailVerified`, `role`
24
+ * or `name` change. An operator's click is not proof of address control for
25
+ * an account someone already holds.
26
+ *
27
+ * ## Email normalization (D7, D8)
28
+ *
29
+ * The lead's email is trimmed and must still be an address (else
30
+ * `invalid_email`, before any read). The lookup is case-insensitive and links
31
+ * the OLDEST match: case-variant rows are one mailbox, and a distinct error
32
+ * for them would reveal global account state. A create uses the lowercased
33
+ * form; one that loses a race to the same address (Prisma `P2002`) re-reads
34
+ * and links the winner.
35
+ *
36
+ * ## Why its own file (D3)
37
+ *
38
+ * `inbox-messages.ts` is co-owned by the `workspace` and `multi-tenant`
39
+ * slices and must build in either bundle alone, while `lead-promotion.ts`
40
+ * sits inside a paid-module import closure. This file imports only
41
+ * `lib/prisma.js` and `zod`, so it keeps both bundles buildable.
42
+ *
43
+ * It never returns, logs or rethrows a Prisma error message — a failure is
44
+ * the bare `internal_error` kind (SEC-006).
45
+ */
46
+
47
+ import { z } from "zod";
48
+
49
+ import { prisma } from "../lib/prisma.js";
50
+
51
+ export type FindOrCreateUserResult =
52
+ | { ok: true; value: { userId: string; created: boolean } }
53
+ | { ok: false; error: { kind: "invalid_email" | "internal_error" } };
54
+
55
+ const EMAIL_SHAPE = z.string().email();
56
+
57
+ /**
58
+ * Prisma's unique-constraint violation, matched by code alone. A local copy
59
+ * of `default-tenant.ts`'s helper, deliberately: importing it would break
60
+ * this file's `lib/prisma.js` + `zod`-only import rule (D3).
61
+ */
62
+ function isUniqueViolation(err: unknown): boolean {
63
+ return (
64
+ typeof err === "object" &&
65
+ err !== null &&
66
+ (err as { code?: unknown }).code === "P2002"
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The oldest user whose email equals `email`, ignoring case. `emailVerified`
72
+ * is selected though no decision here reads it: `tools/lint-email-authz.ts`
73
+ * requires it on every `User` read keyed by email, and this read only links
74
+ * an id — it never gates data on the address.
75
+ */
76
+ function findUserByEmail(email: string) {
77
+ return prisma.user.findFirst({
78
+ where: { email: { equals: email, mode: "insensitive" } },
79
+ orderBy: { createdAt: "asc" },
80
+ select: { id: true, emailVerified: true },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Resolve the lead's email to one `auth.User` id — linked when one exists,
86
+ * created verified when none does.
87
+ *
88
+ * @param input.email the lead's stored email, untrimmed.
89
+ * @returns the user id and whether this call created it (`created` is for
90
+ * the caller's audit line only — never for the wire, SEC-002).
91
+ */
92
+ export async function findOrCreateUserForLeadEmail(input: {
93
+ email: string;
94
+ }): Promise<FindOrCreateUserResult> {
95
+ const email = typeof input.email === "string" ? input.email.trim() : "";
96
+ if (!EMAIL_SHAPE.safeParse(email).success) {
97
+ return { ok: false, error: { kind: "invalid_email" } };
98
+ }
99
+
100
+ try {
101
+ const existing = await findUserByEmail(email);
102
+ if (existing) {
103
+ return { ok: true, value: { userId: existing.id, created: false } };
104
+ }
105
+
106
+ try {
107
+ const created = await prisma.user.create({
108
+ data: {
109
+ email: email.toLowerCase(),
110
+ emailVerified: true,
111
+ role: "user",
112
+ },
113
+ select: { id: true },
114
+ });
115
+ return { ok: true, value: { userId: created.id, created: true } };
116
+ } catch (err) {
117
+ if (!isUniqueViolation(err)) throw err;
118
+ // D8 — a concurrent accept created the same address first.
119
+ const winner = await findUserByEmail(email);
120
+ if (!winner) return { ok: false, error: { kind: "internal_error" } };
121
+ return { ok: true, value: { userId: winner.id, created: false } };
122
+ }
123
+ } catch {
124
+ return { ok: false, error: { kind: "internal_error" } };
125
+ }
126
+ }
@@ -112,6 +112,27 @@ function getServer(): Promise<FastifyServer> {
112
112
  return attempt;
113
113
  }
114
114
 
115
+ /**
116
+ * The request body exactly as the client sent it (#7440).
117
+ *
118
+ * Vercel's Node runtime reads the stream itself to back its lazily PARSED
119
+ * `req.body`, then replays the same bytes onto the request stream
120
+ * (`restoreBody` in @vercel/node's serverless-functions helpers), so they are
121
+ * still readable here — but ONLY through `on("data")`/`on("end")`, the two
122
+ * listeners that replay patches. `once`, `pipe` or `for await` would wait on
123
+ * the already-drained original stream and never settle.
124
+ */
125
+ function readRawBody(req: VercelRequest): Promise<Buffer> {
126
+ return new Promise((resolve, reject) => {
127
+ const chunks: Buffer[] = [];
128
+ req.on("data", (chunk: Buffer | string) => {
129
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
130
+ });
131
+ req.on("end", () => resolve(Buffer.concat(chunks)));
132
+ req.on("error", reject);
133
+ });
134
+ }
135
+
115
136
  /**
116
137
  * Vercel serverless function handler
117
138
  */
@@ -158,17 +179,42 @@ export default async function handler(
158
179
 
159
180
  const server = await getServer();
160
181
 
182
+ // Forward the RAW bytes, never the runtime's parsed `req.body` (#7440).
183
+ // A signed webhook — Stripe, Slack, Cal.com — is verified over the exact
184
+ // bytes the sender signed, and Fastify's raw-buffer parser on the
185
+ // webhooks scope only sees those bytes if they are what inject() is
186
+ // given: handing it the parsed object made light-my-request re-serialize
187
+ // it compactly, so a pretty-printed Stripe event could never verify.
188
+ // Every other route now parses the bytes with Fastify's own parser rather
189
+ // than Vercel's — the same value for any JSON object or array body — and
190
+ // answers exactly as the app does off Vercel: a route with a parser for a
191
+ // content type gets the bytes (one without answers 415), a top-level JSON
192
+ // scalar reaches the route, and `bodyLimit` measures the bytes as sent.
193
+ //
194
+ // An EMPTY body falls back to `req.body`, which keeps the runtime's
195
+ // answer for it — `{}` for a bodiless JSON POST, where Fastify's own JSON
196
+ // parser would refuse the empty string with a 400. So does a request with
197
+ // no content-type (the runtime forwarded no body for one, and Fastify has
198
+ // no parser to give it), and GET/HEAD, which carry no body to read.
199
+ const method = req.method || "GET";
200
+ const rawBody =
201
+ method === "GET" ||
202
+ method === "HEAD" ||
203
+ req.headers["content-type"] === undefined
204
+ ? undefined
205
+ : await readRawBody(req);
206
+ const body = rawBody && rawBody.length > 0 ? rawBody : req.body;
207
+
161
208
  // Strip content-length/content-encoding before forwarding. Both
162
- // describe the ORIGINAL byte stream the client sent; req.body here is
163
- // already the PARSED object, and light-my-request re-serializes it
164
- // itself. It only computes a fresh content-length when the header is
165
- // absent (`req.headers['content-length'] = req.headers['content-length']
166
- // || ...`), so a stale header survives and Fastify's own content-length
167
- // check rejects any body whose re-serialized length differs from the
168
- // original — e.g. pretty-printed JSON or a trailing newline
169
- // (FST_ERR_CTP_INVALID_CONTENT_LENGTH, #5168). Matched case-insensitively
170
- // since header casing isn't guaranteed, even though Node lower-cases
171
- // incoming header names in practice.
209
+ // describe the ORIGINAL byte stream the client sent, and light-my-request
210
+ // only computes a fresh content-length when the header is absent
211
+ // (`req.headers['content-length'] = req.headers['content-length'] ||
212
+ // ...`), so a stale header survives and Fastify's own content-length check
213
+ // rejects any forwarded body whose length differs from the original — as
214
+ // the re-serialized `req.body` fallback above does (an empty body becomes
215
+ // `{}`; FST_ERR_CTP_INVALID_CONTENT_LENGTH, #5168). Matched
216
+ // case-insensitively since header casing isn't guaranteed, even though
217
+ // Node lower-cases incoming header names in practice.
172
218
  const forwardedHeaders = { ...req.headers } as Record<string, string>;
173
219
  for (const key of Object.keys(forwardedHeaders)) {
174
220
  if (
@@ -180,10 +226,10 @@ export default async function handler(
180
226
  }
181
227
 
182
228
  const fastifyRequest = {
183
- method: req.method || "GET",
229
+ method,
184
230
  url: pathname,
185
231
  headers: forwardedHeaders,
186
- body: req.body,
232
+ body,
187
233
  query: req.query as Record<string, string>,
188
234
  };
189
235
 
@@ -9,6 +9,7 @@
9
9
  * 5. API 429 → { ok: false, error: { kind: 'api_error', status: 429 } }
10
10
  */
11
11
 
12
+ import { LEAD_SITE_MARKER_KEY, leadSubmitSchema } from "@working-theory/validation";
12
13
  import { ailkCaptureLead } from "../src/tools/capture_lead.js";
13
14
 
14
15
  // Mock @working-theory/api-client before importing the handler
@@ -33,6 +34,8 @@ const VALID_INPUT = {
33
34
 
34
35
  beforeEach(() => {
35
36
  jest.clearAllMocks();
37
+ // The exact-body assertions below assume no spoke marker (#7397).
38
+ delete process.env.AILK_LEAD_SITE;
36
39
  });
37
40
 
38
41
  describe("ailkCaptureLead — input validation", () => {
@@ -163,3 +166,81 @@ describe("ailkCaptureLead — siteId override", () => {
163
166
  expect(call.siteId).toBe("site-abc");
164
167
  });
165
168
  });
169
+
170
+ // #7397 — a spoke of a hub-and-spokes deployment is marked in the lead
171
+ // PAYLOAD (`payload.site`); `source` stays "mcp" because
172
+ // MESSAGE_LEAD_SOURCE_LIST is frozen. The marker comes from the MCP server's
173
+ // own env, so an MCP-origin lead reaches the hub inbox attributed.
174
+ describe("ailkCaptureLead — payload site marker (#7397)", () => {
175
+ afterEach(() => {
176
+ delete process.env.AILK_LEAD_SITE;
177
+ });
178
+
179
+ it("names the marker key 'site', the key the issue's hub-and-spokes design names", () => {
180
+ expect(LEAD_SITE_MARKER_KEY).toBe("site");
181
+ });
182
+
183
+ it("carries payload.site from AILK_LEAD_SITE, leaving source 'mcp'", async () => {
184
+ process.env.AILK_LEAD_SITE = "spoke-a.example";
185
+ apiClient.leads.submit.mockResolvedValueOnce({
186
+ ok: true,
187
+ body: { id: "lead_00000000-0000-7000-8000-0000000000d4", persisted: "db" },
188
+ });
189
+
190
+ await ailkCaptureLead(VALID_INPUT);
191
+
192
+ expect(apiClient.leads.submit).toHaveBeenCalledWith({
193
+ siteId: "default",
194
+ source: "mcp",
195
+ email: "jane@example.com",
196
+ payload: {
197
+ name: "Jane Smith",
198
+ message: "I am interested in your services.",
199
+ site: "spoke-a.example",
200
+ },
201
+ });
202
+ });
203
+
204
+ it("sends a marked body the API's submit schema accepts", async () => {
205
+ process.env.AILK_LEAD_SITE = "spoke-a.example";
206
+ apiClient.leads.submit.mockResolvedValueOnce({
207
+ ok: true,
208
+ body: { id: "lead_00000000-0000-7000-8000-0000000000d5", persisted: "db" },
209
+ });
210
+
211
+ await ailkCaptureLead(VALID_INPUT);
212
+
213
+ const body = apiClient.leads.submit.mock.calls[0][0] as Record<string, unknown>;
214
+ expect(leadSubmitSchema.safeParse({ kind: "submit", ...body }).success).toBe(true);
215
+ });
216
+
217
+ it.each([
218
+ ["unset", undefined],
219
+ ["empty", ""],
220
+ ["whitespace only", " "],
221
+ ])("omits payload.site when AILK_LEAD_SITE is %s", async (_label, value) => {
222
+ if (value !== undefined) process.env.AILK_LEAD_SITE = value;
223
+ apiClient.leads.submit.mockResolvedValueOnce({
224
+ ok: true,
225
+ body: { id: "lead_00000000-0000-7000-8000-0000000000d6", persisted: "db" },
226
+ });
227
+
228
+ await ailkCaptureLead(VALID_INPUT);
229
+
230
+ const call = apiClient.leads.submit.mock.calls[0][0] as { payload: Record<string, unknown> };
231
+ expect(call.payload).not.toHaveProperty("site");
232
+ });
233
+
234
+ it("trims surrounding whitespace from AILK_LEAD_SITE", async () => {
235
+ process.env.AILK_LEAD_SITE = " spoke-a.example\n";
236
+ apiClient.leads.submit.mockResolvedValueOnce({
237
+ ok: true,
238
+ body: { id: "lead_00000000-0000-7000-8000-0000000000d7", persisted: "db" },
239
+ });
240
+
241
+ await ailkCaptureLead(VALID_INPUT);
242
+
243
+ const call = apiClient.leads.submit.mock.calls[0][0] as { payload: Record<string, unknown> };
244
+ expect(call.payload["site"]).toBe("spoke-a.example");
245
+ });
246
+ });
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { apiClient } from "@working-theory/api-client";
12
12
  import {
13
+ LEAD_SITE_MARKER_KEY,
13
14
  submitLeadInputSchema,
14
15
  submitLeadOutputSchema,
15
16
  type SubmitLeadInput,
@@ -32,6 +33,8 @@ export async function ailkCaptureLead(
32
33
 
33
34
  const { name, email, message, phone } = parsed.data;
34
35
  const siteId = process.env.AILK_SITE_ID ?? "default";
36
+ // The spoke marker (#7397) — unset or blank leaves the payload unmarked.
37
+ const site = process.env.AILK_LEAD_SITE?.trim();
35
38
  const body = {
36
39
  siteId,
37
40
  source: "mcp",
@@ -40,6 +43,7 @@ export async function ailkCaptureLead(
40
43
  name,
41
44
  ...(message !== undefined && { message }),
42
45
  ...(phone !== undefined && { phone }),
46
+ ...(site && { [LEAD_SITE_MARKER_KEY]: site }),
43
47
  },
44
48
  };
45
49
 
@@ -34,7 +34,9 @@ export async function ailkSubscribeNewsletter(
34
34
 
35
35
  const { email } = parsed.data;
36
36
  // firstName is not part of the canonical subscribeSchema (no payload field);
37
- // it is accepted by the MCP input but dropped here for now.
37
+ // it is accepted by the MCP input but dropped here for now. The #7397 spoke
38
+ // marker (LEAD_SITE_MARKER_KEY) is absent for the same reason: a Subscriber
39
+ // row has no payload column, so subscribe has nowhere to carry it.
38
40
  const siteId = process.env.AILK_SITE_ID ?? "default";
39
41
  const body = { siteId, email, source: "mcp" };
40
42
 
@@ -50,6 +50,10 @@ CONTENT_ADAPTER=mdx
50
50
  # — deliberately NOT NEXT_PUBLIC_, and it still wins over this one when both
51
51
  # are set, so an RSC/Server-Action call can target an internal origin the
52
52
  # browser never sees.
53
+ #
54
+ # A spoke calling a hub API server-to-server forwards its visitors for the
55
+ # hub's per-visitor lead limit with AILK_VISITOR_FORWARD_KEY — server-side
56
+ # only, never NEXT_PUBLIC_ (see the repo-root .env.example, #7394).
53
57
  # NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
54
58
 
55
59
  # =============================================================================