create-ailk 0.11.1 → 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 (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @file trusted-origins.server.test.ts
3
+ * @description A spoke's cookie-forwarded write through the REAL server (#7395 AC-8).
4
+ *
5
+ * The defect #7395 reports: a spoke renders `/account` against the hub API and
6
+ * forwards the visitor's cookie with the browser's own `Origin`, and every
7
+ * write came back 401 because the auth preHandler compared that `Origin` with
8
+ * `===` against the one `FRONTEND_URL`. The unit suites build the plugin
9
+ * options themselves, so only a boot through `build()` sees what `server.ts`
10
+ * actually wires — `trustedOrigins: [config.FRONTEND_URL]` would pass every
11
+ * one of them and still refuse the spoke here.
12
+ *
13
+ * Each case rebuilds the module graph (`jest.resetModules()`) because
14
+ * `config/index.ts` resolves the allowlist at import time.
15
+ */
16
+
17
+ // Mocked for the same reasons cors.test.ts gives: rate-limit wants a live
18
+ // store, and @working-theory/email pulls an ESM-only transitive dep.
19
+ jest.mock("@fastify/rate-limit", () => jest.fn().mockResolvedValue(undefined));
20
+
21
+ jest.mock("@working-theory/email", () => ({
22
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
23
+ }));
24
+
25
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors auth.cookie-path.test.ts
26
+ jest.mock("../lib/prisma.js", () => ({
27
+ prisma: {
28
+ session: { findUnique: jest.fn() },
29
+ apiToken: {
30
+ findUnique: jest.fn(),
31
+ create: jest.fn(),
32
+ update: jest.fn().mockResolvedValue(undefined),
33
+ },
34
+ },
35
+ }));
36
+
37
+ const HUB = "https://hub.example";
38
+ const SPOKE_A = "https://spoke-a.example";
39
+ const ATTACKER = "https://attacker.example.net";
40
+ const COOKIE = "better-auth.session_token=sess_tok_spoke.signature";
41
+
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
43
+ type App = any;
44
+
45
+ const ENV_KEYS = [
46
+ "FRONTEND_URL",
47
+ "AILK_TRUSTED_ORIGINS",
48
+ "NODE_ENV",
49
+ "DATABASE_URL",
50
+ // Cleared so a production boot never trips config's database-module exit.
51
+ "AILK_ENABLED_MODULES",
52
+ ] as const;
53
+
54
+ describe("#7395 — a listed spoke's cookie write through build()", () => {
55
+ const priorEnv = new Map<string, string | undefined>(
56
+ ENV_KEYS.map((key) => [key, process.env[key]]),
57
+ );
58
+ let app: App;
59
+ let prisma: {
60
+ session: { findUnique: jest.Mock };
61
+ apiToken: { create: jest.Mock };
62
+ };
63
+
64
+ beforeEach(async () => {
65
+ process.env.FRONTEND_URL = HUB;
66
+ process.env.AILK_TRUSTED_ORIGINS = SPOKE_A;
67
+ process.env.NODE_ENV = "production";
68
+ process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
69
+ delete process.env.AILK_ENABLED_MODULES;
70
+ jest.resetModules();
71
+
72
+ prisma = (require("../lib/prisma.js") as { prisma: typeof prisma }).prisma;
73
+ prisma.session.findUnique.mockResolvedValue({
74
+ id: "ses_1",
75
+ userId: "usr_spoke_visitor",
76
+ expiresAt: new Date(Date.now() + 60_000),
77
+ activeOrganizationId: null,
78
+ activeWorkspaceId: null,
79
+ });
80
+ prisma.apiToken.create.mockResolvedValue({
81
+ id: "tok_1",
82
+ name: "spoke",
83
+ tokenPrefix: "ailk_abc",
84
+ clientType: "cli",
85
+ scopes: [],
86
+ expiresAt: null,
87
+ createdAt: new Date(),
88
+ });
89
+
90
+ const { build } = require("../server") as typeof import("../server");
91
+ app = await build();
92
+ });
93
+
94
+ afterEach(async () => {
95
+ if (app) {
96
+ await app.close();
97
+ app = undefined;
98
+ }
99
+ for (const [key, value] of priorEnv) {
100
+ if (value === undefined) delete process.env[key];
101
+ else process.env[key] = value;
102
+ }
103
+ jest.resetModules();
104
+ });
105
+
106
+ function postToken(origin: string) {
107
+ return app.inject({
108
+ method: "POST",
109
+ url: "/v1/auth/tokens",
110
+ headers: { cookie: COOKIE, origin },
111
+ payload: { name: "spoke" },
112
+ });
113
+ }
114
+
115
+ it("lets a cookie write from the listed spoke past the auth preHandler", async () => {
116
+ const res = await postToken(SPOKE_A);
117
+
118
+ expect(res.statusCode).not.toBe(401);
119
+ expect(res.json()).not.toEqual({ error: "unauthorized" });
120
+ // The handler ran as the session's user: the token was minted for them.
121
+ expect(res.statusCode).toBe(201);
122
+ expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
123
+ expect(prisma.apiToken.create.mock.calls[0][0].data.userId).toBe(
124
+ "usr_spoke_visitor",
125
+ );
126
+ });
127
+
128
+ it("refuses the same cookie write from an unlisted origin with 401", async () => {
129
+ const res = await postToken(ATTACKER);
130
+
131
+ expect(res.statusCode).toBe(401);
132
+ expect(res.json()).toEqual({ error: "unauthorized" });
133
+ expect(prisma.apiToken.create).not.toHaveBeenCalled();
134
+ });
135
+ });
@@ -15,6 +15,11 @@
15
15
  * string) instead of response.rawPayload corrupts any non-UTF-8 response
16
16
  * by substituting U+FFFD for every invalid byte.
17
17
  *
18
+ * #7440 extends the request side: the adapter must forward the RAW bytes the
19
+ * client sent, not the parsed `req.body` — a signed webhook (Stripe, Slack,
20
+ * Cal.com) is verified over the exact bytes, and a re-serialization of the
21
+ * parse never matches a pretty-printed original.
22
+ *
18
23
  * The two request-side tests below drive a REAL minimal Fastify instance
19
24
  * through the handler — the defect lives inside Fastify's own
20
25
  * content-length check, so a mocked inject() cannot fail the way production
@@ -29,6 +34,9 @@
29
34
  * by every later test regardless of what that test wants build() to return.
30
35
  */
31
36
 
37
+ import { createHmac } from 'node:crypto';
38
+ import { PassThrough } from 'node:stream';
39
+
32
40
  import type { VercelRequest, VercelResponse } from '@vercel/node';
33
41
  import Fastify from 'fastify';
34
42
 
@@ -60,6 +68,47 @@ function asRequest(stub: MockVercelRequest): VercelRequest {
60
68
  return stub;
61
69
  }
62
70
 
71
+ /**
72
+ * A body-carrying request the way the Vercel Node runtime hands it over: the
73
+ * runtime's helpers drain the stream to a Buffer, expose a lazily PARSED
74
+ * `req.body`, then replay the very same bytes through a PassThrough that only
75
+ * `on`/`addListener` for `data`/`end` reach (`restoreBody` in @vercel/node's
76
+ * serverless-functions helpers). The stub reproduces exactly that: an
77
+ * already-ended original stream with the replay patched over it, and `body`
78
+ * set to what the runtime's parser produced (`parsed`, default the JSON parse).
79
+ */
80
+ function bodyRequest(fields: {
81
+ method?: string;
82
+ url: string;
83
+ headers: Record<string, string>;
84
+ raw: string;
85
+ parsed?: unknown;
86
+ }): VercelRequest {
87
+ const original = new PassThrough();
88
+ original.end();
89
+ original.resume();
90
+ const replay = new PassThrough();
91
+ replay.end(Buffer.from(fields.raw));
92
+ const originalOn = original.on.bind(original);
93
+ const replayedOn = (name: string, cb: (...args: unknown[]) => void) =>
94
+ name === 'data' || name === 'end' ? replay.on(name, cb) : originalOn(name, cb);
95
+ const stub = Object.assign(original, {
96
+ read: replay.read.bind(replay),
97
+ on: replayedOn,
98
+ addListener: replayedOn,
99
+ method: fields.method ?? 'POST',
100
+ url: fields.url,
101
+ headers: fields.headers,
102
+ body: 'parsed' in fields ? fields.parsed : fields.raw ? JSON.parse(fields.raw) : {},
103
+ query: {},
104
+ });
105
+ // @ts-expect-error: a Readable carrying the fields above satisfies the
106
+ // subset of VercelRequest the handler reads (stream + method/url/headers/
107
+ // body/query); a full VercelRequest (extends http.IncomingMessage) cannot
108
+ // be constructed without a live connection.
109
+ return stub;
110
+ }
111
+
63
112
  interface MockRes {
64
113
  headersSent: boolean;
65
114
  _status?: number;
@@ -134,19 +183,17 @@ describe('vercel-handler — request side: stale content-length (#5168)', () =>
134
183
 
135
184
  const body = { hello: 'world', nested: { a: 1 } };
136
185
  const pretty = JSON.stringify(body, null, 2);
137
- const req: MockVercelRequest = {
138
- method: 'POST',
186
+ const req = bodyRequest({
139
187
  url: '/echo',
140
188
  headers: {
141
189
  'content-type': 'application/json',
142
190
  'content-length': String(Buffer.byteLength(pretty)),
143
191
  },
144
- body,
145
- query: {},
146
- };
192
+ raw: pretty,
193
+ });
147
194
  const res = makeRes();
148
195
 
149
- await handler(asRequest(req), asResponse(res));
196
+ await handler(req, asResponse(res));
150
197
 
151
198
  expect(res._status).toBe(200);
152
199
  expect(JSON.parse(res._sent as string)).toEqual(body);
@@ -158,25 +205,199 @@ describe('vercel-handler — request side: stale content-length (#5168)', () =>
158
205
 
159
206
  const body = { hello: 'world' };
160
207
  const compactPlusNewline = JSON.stringify(body) + '\n';
161
- const req: MockVercelRequest = {
162
- method: 'POST',
208
+ const req = bodyRequest({
163
209
  url: '/echo',
164
210
  headers: {
165
211
  'content-type': 'application/json',
166
212
  'content-length': String(Buffer.byteLength(compactPlusNewline)),
167
213
  },
168
- body,
169
- query: {},
170
- };
214
+ raw: compactPlusNewline,
215
+ });
171
216
  const res = makeRes();
172
217
 
173
- await handler(asRequest(req), asResponse(res));
218
+ await handler(req, asResponse(res));
174
219
 
175
220
  expect(res._status).toBe(200);
176
221
  expect(JSON.parse(res._sent as string)).toEqual(body);
177
222
  });
178
223
  });
179
224
 
225
+ // ── #7440: the signed bytes reach the verifier ──────────────────────────────
226
+ //
227
+ // Stripe signs the ORIGINAL bytes of the event — pretty-printed JSON — and the
228
+ // adapter used to forward the runtime's PARSED `req.body`, which inject()
229
+ // re-serialized compactly, so verifyWebhookSignature could never match on a
230
+ // Vercel deployment. These tests mount the REAL Stripe receiver behind the
231
+ // same raw-buffer parser server.ts scopes to the webhooks sub-router, and sign
232
+ // with the REAL verifier's scheme — only the licence (commerce-claimed), the
233
+ // analytics sink and Prisma are faked, none of which the verify path reads.
234
+
235
+ jest.mock('@working-theory/license', () => ({
236
+ checkLicense: jest.fn().mockResolvedValue({
237
+ tier: 'pro',
238
+ plan: 'pro-monthly',
239
+ features: ['commerce'],
240
+ expiresAt: null,
241
+ revoked: false,
242
+ }),
243
+ readStoredJwtState: jest.fn(),
244
+ }));
245
+ jest.mock('@working-theory/analytics/server', () => ({
246
+ track: jest.fn().mockResolvedValue(undefined),
247
+ }));
248
+ // 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.
249
+ jest.mock('../lib/prisma.js');
250
+
251
+ /** Stripe's v1 scheme: `t=<ts>,v1=<hex HMAC-SHA256(secret, "<ts>.<payload>")>`. */
252
+ function stripeSignature(payload: string, secret: string): string {
253
+ const timestamp = Math.floor(Date.now() / 1000);
254
+ const v1 = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
255
+ return `t=${timestamp},v1=${v1}`;
256
+ }
257
+
258
+ describe('vercel-handler — raw bytes reach webhook verification (#7440)', () => {
259
+ let app: ReturnType<typeof Fastify>;
260
+
261
+ beforeEach(async () => {
262
+ jest.resetModules();
263
+ const { stripeWebhookHandler } = require('../routes/webhooks/stripe');
264
+ app = Fastify();
265
+ await app.register(async (webhooksScope) => {
266
+ webhooksScope.addContentTypeParser(
267
+ 'application/json',
268
+ { parseAs: 'buffer' },
269
+ (_req, body, done) => {
270
+ done(null, body);
271
+ },
272
+ );
273
+ webhooksScope.addContentTypeParser(
274
+ 'application/x-www-form-urlencoded',
275
+ { parseAs: 'buffer' },
276
+ (_req, body, done) => {
277
+ done(null, body);
278
+ },
279
+ );
280
+ webhooksScope.post('/v1/webhooks/stripe', stripeWebhookHandler);
281
+ webhooksScope.post('/v1/form-echo', async (req) => ({
282
+ raw: Buffer.isBuffer(req.body) ? req.body.toString('utf8') : null,
283
+ }));
284
+ });
285
+ app.post('/echo', async (req) => req.body);
286
+ await app.ready();
287
+ jest.doMock('../server', () => ({ build: jest.fn().mockResolvedValue(app) }));
288
+ });
289
+
290
+ afterEach(async () => {
291
+ await app.close();
292
+ });
293
+
294
+ function webhookSecret(): string {
295
+ const { config } = require('../config') as { config: { STRIPE_WEBHOOK_SECRET?: string } };
296
+ if (!config.STRIPE_WEBHOOK_SECRET) throw new Error('test setup sets STRIPE_WEBHOOK_SECRET');
297
+ return config.STRIPE_WEBHOOK_SECRET;
298
+ }
299
+
300
+ it('verifies a Stripe-signed, pretty-printed event and returns 200', async () => {
301
+ const handler = loadHandler();
302
+ // Stripe's own delivery format: indent=2, as every webhook body arrives.
303
+ const event = JSON.stringify(
304
+ {
305
+ id: 'evt_7440_pretty_printed',
306
+ object: 'event',
307
+ type: 'test.helper.unhandled',
308
+ created: 1_759_000_000,
309
+ data: { object: { id: 'obj_7440' } },
310
+ },
311
+ null,
312
+ 2,
313
+ );
314
+ const req = bodyRequest({
315
+ url: '/v1/webhooks/stripe',
316
+ headers: {
317
+ 'content-type': 'application/json; charset=utf-8',
318
+ 'content-length': String(Buffer.byteLength(event)),
319
+ 'stripe-signature': stripeSignature(event, webhookSecret()),
320
+ },
321
+ raw: event,
322
+ });
323
+ const res = makeRes();
324
+
325
+ await handler(req, asResponse(res));
326
+
327
+ expect(res._status).toBe(200);
328
+ expect(JSON.parse(String(res._sent))).toEqual({ received: true });
329
+ });
330
+
331
+ it('still rejects a body that differs from the signed bytes', async () => {
332
+ const handler = loadHandler();
333
+ const signed = JSON.stringify({ id: 'evt_7440_tampered', type: 'test.helper' }, null, 2);
334
+ const sent = signed.replace('evt_7440_tampered', 'evt_7440_tampereD');
335
+ const req = bodyRequest({
336
+ url: '/v1/webhooks/stripe',
337
+ headers: {
338
+ 'content-type': 'application/json',
339
+ 'stripe-signature': stripeSignature(signed, webhookSecret()),
340
+ },
341
+ raw: sent,
342
+ });
343
+ const res = makeRes();
344
+
345
+ await handler(req, asResponse(res));
346
+
347
+ expect(res._status).toBe(400);
348
+ expect(JSON.parse(String(res._sent))).toEqual({ error: 'signature-invalid' });
349
+ });
350
+
351
+ it('forwards a urlencoded body byte-for-byte to the raw-buffer scope (Slack)', async () => {
352
+ const handler = loadHandler();
353
+ const form = 'payload=%7B%22type%22%3A%22block_actions%22%7D&token=abc';
354
+ const req = bodyRequest({
355
+ url: '/v1/form-echo',
356
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
357
+ raw: form,
358
+ parsed: { payload: '{"type":"block_actions"}', token: 'abc' },
359
+ });
360
+ const res = makeRes();
361
+
362
+ await handler(req, asResponse(res));
363
+
364
+ expect(res._status).toBe(200);
365
+ expect(JSON.parse(String(res._sent))).toEqual({ raw: form });
366
+ });
367
+
368
+ // The fallback re-serializes `{}` (2 bytes) against a declared length of 0,
369
+ // so this also guards the content-length strip (#5168).
370
+ it('keeps an empty JSON POST answering as it did (the runtime parse, `{}`)', async () => {
371
+ const handler = loadHandler();
372
+ const req = bodyRequest({
373
+ url: '/echo',
374
+ headers: { 'content-type': 'application/json', 'content-length': '0' },
375
+ raw: '',
376
+ });
377
+ const res = makeRes();
378
+
379
+ await handler(req, asResponse(res));
380
+
381
+ expect(res._status).toBe(200);
382
+ expect(JSON.parse(String(res._sent))).toEqual({});
383
+ });
384
+
385
+ it('keeps a POST with no content-type bodiless, as the runtime forwarded it', async () => {
386
+ const handler = loadHandler();
387
+ const req = bodyRequest({
388
+ url: '/echo',
389
+ headers: { 'content-length': '5' },
390
+ raw: 'hello',
391
+ parsed: undefined,
392
+ });
393
+ const res = makeRes();
394
+
395
+ await handler(req, asResponse(res));
396
+
397
+ expect(res._status).toBe(200);
398
+ });
399
+ });
400
+
180
401
  describe('vercel-handler — response side: binary round-trip (#5168)', () => {
181
402
  beforeEach(() => {
182
403
  jest.resetModules();
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @file trusted-origins.test.ts
3
+ * @description The trusted browser-origin allowlist (#7395 AC-2).
4
+ *
5
+ * `resolveTrustedOrigins` is the one derivation CORS, the cookie-forwarded
6
+ * write check and the checkout redirect lock all read (spec D2/D4). It takes
7
+ * `FRONTEND_URL` (lenient, as today) plus the opt-in `AILK_TRUSTED_ORIGINS`
8
+ * (strict: an entry must already be written exactly as a browser serialises
9
+ * an `Origin` header). A refused entry stops the process at startup (D3), and
10
+ * the refusal log names the entry's position and reason, never its text.
11
+ */
12
+
13
+ // Mock dotenv so a developer's local .env never leaks into the config module.
14
+ jest.mock('dotenv', () => ({
15
+ config: jest.fn(),
16
+ }));
17
+
18
+ const mockLoggerError = jest.fn();
19
+ const mockLoggerWarn = jest.fn();
20
+ jest.mock('@working-theory/observability', () => ({
21
+ logger: {
22
+ error: (...args: unknown[]) => mockLoggerError(...args),
23
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
24
+ info: jest.fn(),
25
+ debug: jest.fn(),
26
+ },
27
+ }));
28
+
29
+ import { resolveTrustedOrigins } from '../index';
30
+
31
+ describe('resolveTrustedOrigins (#7395 D2)', () => {
32
+ it('normalises the primary, trims and de-duplicates the extras, and keeps first-seen order', () => {
33
+ const result = resolveTrustedOrigins({
34
+ FRONTEND_URL: 'https://hub.example/',
35
+ AILK_TRUSTED_ORIGINS:
36
+ ' https://admin.hub.example,https://spoke-a.example,,https://hub.example',
37
+ NODE_ENV: 'production',
38
+ });
39
+
40
+ expect(result.origins).toEqual([
41
+ 'https://hub.example',
42
+ 'https://admin.hub.example',
43
+ 'https://spoke-a.example',
44
+ ]);
45
+ expect(result.refused).toEqual([]);
46
+ expect(result.primaryUnusable).toBe(false);
47
+ expect(Object.isFrozen(result.origins)).toBe(true);
48
+ });
49
+
50
+ it.each([
51
+ ['*', 'wildcard'],
52
+ ['https://*.example.com', 'wildcard'],
53
+ ['null', 'unparseable'],
54
+ ['https://a.example/', 'not-canonical'],
55
+ ['https://a.example/app', 'not-canonical'],
56
+ ['HTTPS://A.EXAMPLE', 'not-canonical'],
57
+ ['https://a.example:443', 'not-canonical'],
58
+ ['ftp://a.example', 'scheme'],
59
+ ['not a url', 'unparseable'],
60
+ ['https://user:pw@a.example', 'not-canonical'],
61
+ ['https://bücher.example', 'not-canonical'],
62
+ ['https://a.example.', 'trailing-dot'],
63
+ ['http://a.example', 'scheme'],
64
+ ])('refuses the extra %j in production (%s)', (entry, reason) => {
65
+ const result = resolveTrustedOrigins({
66
+ FRONTEND_URL: 'https://hub.example',
67
+ AILK_TRUSTED_ORIGINS: `https://ok.example,${entry}`,
68
+ NODE_ENV: 'production',
69
+ });
70
+
71
+ expect(result.refused).toEqual([{ position: 2, reason }]);
72
+ expect(result.origins).toEqual(['https://hub.example', 'https://ok.example']);
73
+ });
74
+
75
+ it('accepts a plain-http extra outside production', () => {
76
+ const result = resolveTrustedOrigins({
77
+ FRONTEND_URL: 'http://localhost:3000',
78
+ AILK_TRUSTED_ORIGINS: 'http://a.example',
79
+ NODE_ENV: 'development',
80
+ });
81
+
82
+ expect(result.refused).toEqual([]);
83
+ expect(result.origins).toEqual(['http://localhost:3000', 'http://a.example']);
84
+ });
85
+
86
+ it('carries no raw entry text in its result', () => {
87
+ const result = resolveTrustedOrigins({
88
+ FRONTEND_URL: 'https://hub.example',
89
+ AILK_TRUSTED_ORIGINS: 'https://user:pw@a.example',
90
+ NODE_ENV: 'production',
91
+ });
92
+
93
+ expect(JSON.stringify(result)).not.toContain('pw');
94
+ });
95
+
96
+ it.each([
97
+ 'https://*',
98
+ // WHATWG decodes `%2A` in a host, so this parses to origin https://*.example.com.
99
+ 'https://%2A.example.com',
100
+ 'https://a.example,https://b.example',
101
+ 'not a url',
102
+ 'mailto:ops@example.com',
103
+ ])(
104
+ 'a FRONTEND_URL of %j contributes no origin and is reported unusable',
105
+ (frontendUrl) => {
106
+ const result = resolveTrustedOrigins({
107
+ FRONTEND_URL: frontendUrl,
108
+ AILK_TRUSTED_ORIGINS: 'https://spoke-a.example',
109
+ NODE_ENV: 'production',
110
+ });
111
+
112
+ expect(result.origins).toEqual(['https://spoke-a.example']);
113
+ expect(result.primaryUnusable).toBe(true);
114
+ expect(result.refused).toEqual([]);
115
+ },
116
+ );
117
+
118
+ it('keeps a plain-http primary in production, as today', () => {
119
+ const result = resolveTrustedOrigins({
120
+ FRONTEND_URL: 'http://site.example',
121
+ NODE_ENV: 'production',
122
+ });
123
+
124
+ expect(result.origins).toEqual(['http://site.example']);
125
+ expect(result.primaryUnusable).toBe(false);
126
+ });
127
+
128
+ it('defaults an unset primary to http://localhost:3000 outside production', () => {
129
+ const result = resolveTrustedOrigins({ NODE_ENV: 'development' });
130
+
131
+ expect(result.origins).toEqual(['http://localhost:3000']);
132
+ expect(result.primaryUnusable).toBe(false);
133
+ });
134
+
135
+ it('trusts nothing for an unset primary in production, without reporting it unusable', () => {
136
+ const result = resolveTrustedOrigins({ NODE_ENV: 'production' });
137
+
138
+ expect(result.origins).toEqual([]);
139
+ expect(result.primaryUnusable).toBe(false);
140
+ });
141
+
142
+ it('trusts only the extras for an unset primary in production', () => {
143
+ const result = resolveTrustedOrigins({
144
+ AILK_TRUSTED_ORIGINS: 'https://hub.example',
145
+ NODE_ENV: 'production',
146
+ });
147
+
148
+ expect(result.origins).toEqual(['https://hub.example']);
149
+ });
150
+ });
151
+
152
+ describe('config.TRUSTED_ORIGINS at startup (#7395 D3/D5)', () => {
153
+ const originalEnv = process.env;
154
+
155
+ beforeEach(() => {
156
+ jest.resetModules();
157
+ process.env = { ...originalEnv };
158
+ // Cleared so a production boot never trips config's database-module exit.
159
+ delete process.env.AILK_ENABLED_MODULES;
160
+ jest.clearAllMocks();
161
+ });
162
+
163
+ afterEach(() => {
164
+ process.env = originalEnv;
165
+ jest.restoreAllMocks();
166
+ });
167
+
168
+ it('exposes the resolved, frozen list on config', () => {
169
+ process.env.FRONTEND_URL = 'https://hub.example/';
170
+ process.env.AILK_TRUSTED_ORIGINS = 'https://spoke-a.example';
171
+
172
+ const { config } = require('../index');
173
+
174
+ expect(config.TRUSTED_ORIGINS).toEqual(['https://hub.example', 'https://spoke-a.example']);
175
+ expect(Object.isFrozen(config.TRUSTED_ORIGINS)).toBe(true);
176
+ expect(config.AILK_TRUSTED_ORIGINS).toBe('https://spoke-a.example');
177
+ // FRONTEND_URL keeps its job as the primary site's address (D7).
178
+ expect(config.FRONTEND_URL).toBe('https://hub.example/');
179
+ });
180
+
181
+ it.each(['production', 'development'])(
182
+ 'exits with a refused entry in %s, logging its position and reason but never its text',
183
+ (nodeEnv) => {
184
+ process.env.NODE_ENV = nodeEnv;
185
+ process.env.FRONTEND_URL = 'https://hub.example';
186
+ process.env.AILK_TRUSTED_ORIGINS = 'https://spoke-a.example,https://user:pw@a.example';
187
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
188
+ throw new Error('Process exit called');
189
+ });
190
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
191
+
192
+ expect(() => require('../index')).toThrow('Process exit called');
193
+ expect(exitSpy).toHaveBeenCalledWith(1);
194
+ expect(mockLoggerError).toHaveBeenCalledTimes(1);
195
+
196
+ const logged = JSON.stringify([mockLoggerError.mock.calls, consoleError.mock.calls]);
197
+ expect(logged).toContain('AILK_TRUSTED_ORIGINS');
198
+ expect(logged).toContain('"position":2');
199
+ expect(logged).toContain('"reason":"not-canonical"');
200
+ expect(logged).not.toContain('pw');
201
+ expect(logged).not.toContain('a.example');
202
+ // The same line also reaches console.error, which survives a worker
203
+ // transport dropping the last line before exit (spec § 9 SA-08).
204
+ expect(consoleError).toHaveBeenCalledTimes(1);
205
+ },
206
+ );
207
+
208
+ it.each(['production', 'development'])(
209
+ 'logs, without exiting or echoing it, a FRONTEND_URL that is set but unusable (%s)',
210
+ (nodeEnv) => {
211
+ process.env.NODE_ENV = nodeEnv;
212
+ process.env.FRONTEND_URL = 'https://secret-token*@hub.example';
213
+ delete process.env.AILK_TRUSTED_ORIGINS;
214
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
215
+ throw new Error('Process exit called');
216
+ });
217
+
218
+ const { config } = require('../index');
219
+
220
+ expect(exitSpy).not.toHaveBeenCalled();
221
+ expect(config.TRUSTED_ORIGINS).toEqual([]);
222
+ expect(mockLoggerError).toHaveBeenCalledTimes(1);
223
+ const logged = JSON.stringify(mockLoggerError.mock.calls);
224
+ expect(logged).toContain('FRONTEND_URL is set but');
225
+ expect(logged).not.toContain('secret-token');
226
+ },
227
+ );
228
+
229
+ it('logs no error for a usable FRONTEND_URL and no extras', () => {
230
+ process.env.FRONTEND_URL = 'https://hub.example';
231
+ delete process.env.AILK_TRUSTED_ORIGINS;
232
+
233
+ const { config } = require('../index');
234
+
235
+ expect(config.TRUSTED_ORIGINS).toEqual(['https://hub.example']);
236
+ expect(mockLoggerError).not.toHaveBeenCalled();
237
+ });
238
+ });