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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +41 -28
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +29 -5
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- 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
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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
|
|
229
|
+
method,
|
|
184
230
|
url: pathname,
|
|
185
231
|
headers: forwardedHeaders,
|
|
186
|
-
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
|
# =============================================================================
|