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
@@ -43,6 +43,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
43
43
 
44
44
  import {
45
45
  sendEmailVerificationEmail,
46
+ sendMagicLink,
46
47
  sendPasswordChangedEmail,
47
48
  sendPasswordResetEmail,
48
49
  } from "@working-theory/email";
@@ -75,6 +76,7 @@ const KINDS = new Set<AuthEmailKind>([
75
76
  "password-reset",
76
77
  "email-verification",
77
78
  "password-changed",
79
+ "magic-link",
78
80
  ]);
79
81
 
80
82
  type RelayBody = {
@@ -134,10 +136,13 @@ function parseBody(rawBody: string): RelayBody | null {
134
136
  ) {
135
137
  return null;
136
138
  }
137
- // The two link-carrying kinds need a url; the notice must NOT have one
139
+ // The three link-carrying kinds need a url; the notice must NOT have one
138
140
  // acted on even if a caller supplies it (§ D17 — the notice carries no
139
141
  // credential, and that is enforced by the sender's input type too).
140
- const needsUrl = kind === "password-reset" || kind === "email-verification";
142
+ const needsUrl =
143
+ kind === "password-reset" ||
144
+ kind === "email-verification" ||
145
+ kind === "magic-link";
141
146
  if (needsUrl && (typeof url !== "string" || url.length === 0)) return null;
142
147
  if (expiresInMinutes !== undefined && typeof expiresInMinutes !== "number") {
143
148
  return null;
@@ -152,6 +157,34 @@ function parseBody(rawBody: string): RelayBody | null {
152
157
  };
153
158
  }
154
159
 
160
+ /**
161
+ * One fixed template per kind. Exhaustive: a kind added to `AuthEmailKind`
162
+ * without a branch here fails the type check.
163
+ */
164
+ function sendFor(
165
+ kind: AuthEmailKind,
166
+ to: string,
167
+ linkInput: { to: string; url: string; fromAddress: string; expiresInMinutes?: number },
168
+ fromAddress: string,
169
+ ) {
170
+ switch (kind) {
171
+ case "password-changed":
172
+ return sendPasswordChangedEmail({ to, fromAddress });
173
+ case "email-verification":
174
+ return sendEmailVerificationEmail(linkInput);
175
+ case "password-reset":
176
+ return sendPasswordResetEmail(linkInput);
177
+ case "magic-link":
178
+ // The template's copy fixes its own lifetime ("expires in 5 minutes")
179
+ // and its input has no expiry field, so none is forwarded (#7435 § D4).
180
+ return sendMagicLink({ to, url: linkInput.url, fromAddress });
181
+ default: {
182
+ const unreachable: never = kind;
183
+ throw new Error(`unhandled auth email kind: ${String(unreachable)}`);
184
+ }
185
+ }
186
+ }
187
+
155
188
  export const transactionalEmailRoute: FastifyPluginAsync = async (fastify) => {
156
189
  fastify.post(
157
190
  "/v1/auth/transactional-email",
@@ -229,7 +262,7 @@ export const transactionalEmailRoute: FastifyPluginAsync = async (fastify) => {
229
262
  }
230
263
 
231
264
  // `kind` selects from FIXED templates. A caller supplies no subject and
232
- // no copy — only a recipient and, for the two link kinds, a URL.
265
+ // no copy — only a recipient and, for the three link kinds, a URL.
233
266
  const linkInput = {
234
267
  to: body.to,
235
268
  url: body.url ?? "",
@@ -238,12 +271,7 @@ export const transactionalEmailRoute: FastifyPluginAsync = async (fastify) => {
238
271
  ? { expiresInMinutes: body.expiresInMinutes }
239
272
  : {}),
240
273
  };
241
- const result =
242
- body.kind === "password-changed"
243
- ? await sendPasswordChangedEmail({ to: body.to, fromAddress })
244
- : body.kind === "email-verification"
245
- ? await sendEmailVerificationEmail(linkInput)
246
- : await sendPasswordResetEmail(linkInput);
274
+ const result = await sendFor(body.kind, body.to, linkInput, fromAddress);
247
275
 
248
276
  if (!result.ok) {
249
277
  // `error.kind` only — `error.message` can quote an address (§ D12).
@@ -91,7 +91,10 @@ import { checkoutRoutes } from "../index";
91
91
  // #6877 — pin the suite's site origin to the fixtures' own origin, so every
92
92
  // anonymous case below sends same-origin redirects. Runs before any
93
93
  // `describe` is collected, so the #6866 block's SAME_ORIGIN reads it too.
94
+ // #7395 — the redirect lock and the buyer's cookie check read the trusted
95
+ // allowlist, not FRONTEND_URL, so the suite pins both.
94
96
  config.FRONTEND_URL = "https://example.com";
97
+ config.TRUSTED_ORIGINS = ["https://example.com"];
95
98
 
96
99
  // ── Helpers ─────────────────────────────────────────────────────────────────
97
100
 
@@ -1298,6 +1301,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1298
1301
  beforeEach(async () => {
1299
1302
  jest.clearAllMocks();
1300
1303
  config.FRONTEND_URL = SITE_ORIGIN;
1304
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN];
1301
1305
  process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
1302
1306
  delete process.env["OSS_API_INTERNAL_SECRET"];
1303
1307
  mockAssertSandboxKey.mockImplementation(() => undefined);
@@ -1328,6 +1332,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1328
1332
  delete process.env["STRIPE_SECRET_KEY"];
1329
1333
  delete process.env["OSS_API_INTERNAL_SECRET"];
1330
1334
  config.FRONTEND_URL = SITE_ORIGIN;
1335
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN];
1331
1336
  if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
1332
1337
  else process.env.DATABASE_URL = originalDatabaseUrl;
1333
1338
  });
@@ -1453,45 +1458,24 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1453
1458
  expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1454
1459
  });
1455
1460
 
1456
- it.each([
1457
- [
1458
- "an unparseable FRONTEND_URL",
1459
- () => {
1460
- config.FRONTEND_URL = "not a url";
1461
- },
1462
- ],
1463
- [
1464
- "a non-http(s) FRONTEND_URL",
1465
- () => {
1466
- config.FRONTEND_URL = "mailto:ops@example.com";
1467
- },
1468
- ],
1469
- [
1470
- "an unset FRONTEND_URL in production",
1471
- () => {
1472
- delete process.env.FRONTEND_URL;
1473
- config.NODE_ENV = "production";
1474
- },
1475
- ],
1476
- ])(
1477
- "#6877 R8: %s answers 500 config-error with no Stripe call",
1478
- async (_label, arrange) => {
1479
- const envFrontendUrl = process.env.FRONTEND_URL;
1480
- const nodeEnv = config.NODE_ENV;
1481
- try {
1482
- arrange();
1483
- const res = await post(VALID_BODY);
1484
-
1485
- expect(res.statusCode).toBe(500);
1486
- expect(res.json()).toEqual({ error: "config-error" });
1487
- expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1488
- } finally {
1489
- config.FRONTEND_URL = SITE_ORIGIN;
1490
- config.NODE_ENV = nodeEnv;
1491
- process.env.FRONTEND_URL = envFrontendUrl;
1492
- }
1493
- },
1494
- );
1461
+ // #7395 D4 — what an unusable or unset FRONTEND_URL did here before
1462
+ // (#6877 R8) is now an empty trusted list: config resolves both to `[]`
1463
+ // (config/__tests__/trusted-origins.test.ts), and the route answers the
1464
+ // same 500.
1465
+ it("#6877 R8: an empty trusted-origin list answers 500 config-error with no Stripe call", async () => {
1466
+ config.TRUSTED_ORIGINS = [];
1467
+
1468
+ const res = await post(VALID_BODY);
1469
+
1470
+ expect(res.statusCode).toBe(500);
1471
+ expect(res.json()).toEqual({ error: "config-error" });
1472
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1473
+ expect(
1474
+ logLines.some(
1475
+ (l) => l["msg"] === "[checkout] no trusted browser origin is configured",
1476
+ ),
1477
+ ).toBe(true);
1478
+ });
1495
1479
 
1496
1480
  it("#6877 R9: a refused request never reads the session cookie's row", async () => {
1497
1481
  process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
@@ -1514,3 +1498,148 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
1514
1498
  expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1515
1499
  });
1516
1500
  });
1501
+
1502
+ // ── Trusted-origin allowlist (#7395) ──────────────────────────────────────────
1503
+
1504
+ describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () => {
1505
+ const SITE_ORIGIN = "https://example.com";
1506
+ const SPOKE_A = "https://spoke-a.example";
1507
+ const SESSION_TOKEN = "sess_tok_7395";
1508
+ const COOKIE = `better-auth.session_token=${SESSION_TOKEN}.signature`;
1509
+ const originalDatabaseUrl = process.env.DATABASE_URL;
1510
+
1511
+ let app: FastifyInstance;
1512
+
1513
+ function post(
1514
+ payload: Record<string, unknown>,
1515
+ headers: Record<string, string> = {},
1516
+ ) {
1517
+ return app.inject({
1518
+ method: "POST",
1519
+ url: "/v1/checkout/sessions",
1520
+ headers,
1521
+ payload,
1522
+ });
1523
+ }
1524
+
1525
+ beforeEach(async () => {
1526
+ jest.clearAllMocks();
1527
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN, SPOKE_A];
1528
+ process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
1529
+ delete process.env["OSS_API_INTERNAL_SECRET"];
1530
+ mockAssertSandboxKey.mockImplementation(() => undefined);
1531
+ mockCreateCheckoutSession.mockResolvedValue({
1532
+ sessionId: "cs_test_allow7395",
1533
+ url: "https://checkout.stripe.com/c/pay/cs_test_allow7395",
1534
+ });
1535
+
1536
+ app = buildApp();
1537
+ await app.ready();
1538
+ });
1539
+
1540
+ afterEach(async () => {
1541
+ await app.close();
1542
+ delete process.env["STRIPE_SECRET_KEY"];
1543
+ config.TRUSTED_ORIGINS = [SITE_ORIGIN];
1544
+ (prisma.session.findUnique as jest.Mock).mockReset();
1545
+ (prisma.user.findUnique as jest.Mock).mockReset();
1546
+ if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
1547
+ else process.env.DATABASE_URL = originalDatabaseUrl;
1548
+ });
1549
+
1550
+ it("accepts an anonymous checkout with both redirects on a listed spoke", async () => {
1551
+ const successUrl = `${SPOKE_A}/purchase/success`;
1552
+ const cancelUrl = `${SPOKE_A}/purchase/cancel`;
1553
+
1554
+ const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
1555
+
1556
+ expect(res.statusCode).toBe(201);
1557
+ expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1558
+ expect.objectContaining({ successUrl, cancelUrl }),
1559
+ );
1560
+ });
1561
+
1562
+ it("accepts redirects split across two listed origins", async () => {
1563
+ const successUrl = `${SPOKE_A}/purchase/success`;
1564
+ const cancelUrl = `${SITE_ORIGIN}/pricing`;
1565
+
1566
+ const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
1567
+
1568
+ expect(res.statusCode).toBe(201);
1569
+ expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
1570
+ expect.objectContaining({ successUrl, cancelUrl }),
1571
+ );
1572
+ });
1573
+
1574
+ it.each([
1575
+ ["successUrl", "https://spoke-b.example/x"],
1576
+ ["successUrl", "https://spoke-a.example.evil.test/x"],
1577
+ ["cancelUrl", "https://spoke-b.example/x"],
1578
+ ["cancelUrl", "https://spoke-a.example.evil.test/x"],
1579
+ ])(
1580
+ "refuses a %s on the unlisted %j with 400 naming the field and no Stripe call",
1581
+ async (field, url) => {
1582
+ const res = await post({ ...VALID_BODY, [field]: url });
1583
+
1584
+ expect(res.statusCode).toBe(400);
1585
+ const body = res.json();
1586
+ expect(body.error).toBe("validation-error");
1587
+ expect(body.details).toHaveLength(1);
1588
+ expect(body.details[0].path).toEqual([field]);
1589
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1590
+ },
1591
+ );
1592
+
1593
+ it("answers 500 config-error with no Stripe call when the list is empty", async () => {
1594
+ config.TRUSTED_ORIGINS = [];
1595
+
1596
+ const res = await post({
1597
+ ...VALID_BODY,
1598
+ successUrl: `${SPOKE_A}/purchase/success`,
1599
+ });
1600
+
1601
+ expect(res.statusCode).toBe(500);
1602
+ expect(res.json()).toEqual({ error: "config-error" });
1603
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
1604
+ });
1605
+
1606
+ it("signs the buyer in for a valid cookie forwarded with a listed spoke's Origin", async () => {
1607
+ process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
1608
+ (prisma.session.findUnique as jest.Mock).mockResolvedValue({
1609
+ id: "ses_1",
1610
+ userId: "usr_buyer",
1611
+ expiresAt: new Date(Date.now() + 60_000),
1612
+ activeOrganizationId: null,
1613
+ activeWorkspaceId: null,
1614
+ });
1615
+ (prisma.user.findUnique as jest.Mock).mockImplementation(
1616
+ async (args: { select: Record<string, boolean> }) => {
1617
+ const row: Record<string, unknown> = {
1618
+ id: "usr_buyer",
1619
+ email: "buyer@example.com",
1620
+ emailVerified: true,
1621
+ billingProvider: null,
1622
+ billingCustomerId: null,
1623
+ };
1624
+ return Object.fromEntries(
1625
+ Object.keys(args.select).map((k) => [k, row[k]]),
1626
+ );
1627
+ },
1628
+ );
1629
+
1630
+ const res = await post(
1631
+ {
1632
+ productCode: "ailk-pro",
1633
+ successUrl: `${SPOKE_A}/purchase/success`,
1634
+ cancelUrl: `${SPOKE_A}/purchase/cancel`,
1635
+ },
1636
+ { cookie: COOKIE, origin: SPOKE_A },
1637
+ );
1638
+
1639
+ expect(res.statusCode).toBe(201);
1640
+ expect(mockCreateCheckoutSession.mock.calls[0][0].buyer).toEqual({
1641
+ userId: "usr_buyer",
1642
+ email: "buyer@example.com",
1643
+ });
1644
+ });
1645
+ });
@@ -28,13 +28,14 @@
28
28
  * (backward-compat for OSS standalone deployments). See #935.
29
29
  * - Same-origin redirects (#6877): a request that did not pass an ENABLED
30
30
  * internal-auth gate (the OSS default) must send successUrl and cancelUrl
31
- * on the site's own origin — `FRONTEND_URL`'s, exact scheme + host + port,
32
- * checked on the raw string as well as on the parse, because the raw
33
- * string is what Stripe receives. Anything else is a 400 before any Stripe
34
- * call or database read. A caller authenticated by X-Internal-Auth keeps
35
- * its redirects; that exemption holds only while every internal caller
36
- * builds its redirects itself — an internal caller MUST NEVER forward a
37
- * redirect taken from its own end user.
31
+ * each on one of the trusted browser origins — `config.TRUSTED_ORIGINS`
32
+ * (#7395: FRONTEND_URL's origin plus AILK_TRUSTED_ORIGINS), exact scheme +
33
+ * host + port, checked on the raw string as well as on the parse, because
34
+ * the raw string is what Stripe receives. Anything else is a 400 before
35
+ * any Stripe call or database read. A caller authenticated by
36
+ * X-Internal-Auth keeps its redirects; that exemption holds only while
37
+ * every internal caller builds its redirects itself — an internal caller
38
+ * MUST NEVER forward a redirect taken from its own end user.
38
39
  *
39
40
  * Error mapping:
40
41
  * 401 internal-auth-required — X-Internal-Auth missing/mismatch (only when env var set)
@@ -44,8 +45,8 @@
44
45
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
45
46
  * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
46
47
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
47
- * or FRONTEND_URL is not an absolute http(s) URL / is unset in
48
- * production when the origin lock applies (#6877)
48
+ * or no trusted browser origin is configured when the origin
49
+ * lock applies (#6877, #7395)
49
50
  * 502 checkout-unavailable — Stripe SDK threw an unexpected error
50
51
  */
51
52
  import { timingSafeEqual } from "node:crypto";
@@ -154,26 +155,6 @@ function internalAuthVerdict(
154
155
 
155
156
  // ── Same-origin redirect lock (#6877) ────────────────────────────────────────
156
157
 
157
- /**
158
- * The site's own origin, from `FRONTEND_URL` (#6877 D6). `null` when the
159
- * setting does not parse, parses to a non-http(s) scheme (whose origin would
160
- * be the literal `"null"`), or is unset in production — where the config
161
- * default `http://localhost:3000` would lock a live site to localhost.
162
- */
163
- function siteRedirectOrigin(): string | null {
164
- if (!process.env["FRONTEND_URL"] && config.NODE_ENV === "production") {
165
- return null;
166
- }
167
- let parsed: URL;
168
- try {
169
- parsed = new URL(config.FRONTEND_URL);
170
- } catch {
171
- return null;
172
- }
173
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
174
- return parsed.origin;
175
- }
176
-
177
158
  /** C0 controls, space, DEL and backslash — anything a parser may normalize. */
178
159
  // eslint-disable-next-line no-control-regex -- the control-character range is exactly what D2 check 1 must refuse.
179
160
  const NON_CANONICAL_URL_CHAR = /[\u0000-\u0020\u007f\\]/;
@@ -225,7 +206,7 @@ async function resolveBuyer(
225
206
  request: FastifyRequest,
226
207
  ): Promise<{ buyer?: Buyer; identity?: IdentityReason }> {
227
208
  const cookieUser = await resolveOptionalCookieUser(request, {
228
- frontendOrigin: config.FRONTEND_URL,
209
+ trustedOrigins: config.TRUSTED_ORIGINS,
229
210
  });
230
211
  if (cookieUser.kind === "none") {
231
212
  return cookieUser.reason === "no_cookie"
@@ -279,16 +260,21 @@ export async function checkoutSessionsHandler(
279
260
 
280
261
  // ── Same-origin redirect lock (#6877) — before any Stripe or DB call ──────
281
262
  if (authVerdict === "open") {
282
- const siteOrigin = siteRedirectOrigin();
283
- if (siteOrigin === null) {
263
+ // An empty list is FRONTEND_URL unset in production or unusable, with no
264
+ // AILK_TRUSTED_ORIGINS (#7395 D4) — refused before any Stripe or DB call.
265
+ const trustedOrigins = config.TRUSTED_ORIGINS;
266
+ if (trustedOrigins.length === 0) {
284
267
  request.log.error(
285
268
  { method: request.method, path: request.url },
286
- "[checkout] FRONTEND_URL is not a valid absolute URL",
269
+ "[checkout] no trusted browser origin is configured",
287
270
  );
288
271
  return reply.status(500).send({ error: "config-error" });
289
272
  }
290
273
  const refused = (["successUrl", "cancelUrl"] as const).filter(
291
- (field) => !isSameOriginRedirect(parsed.data[field], siteOrigin),
274
+ (field) =>
275
+ !trustedOrigins.some((origin) =>
276
+ isSameOriginRedirect(parsed.data[field], origin),
277
+ ),
292
278
  );
293
279
  if (refused.length > 0) {
294
280
  // Field names only — the refused URL is never logged or echoed.
@@ -267,7 +267,7 @@ function installFixtureMocks(): void {
267
267
  async function buildApp(): Promise<FastifyInstance> {
268
268
  const app = Fastify();
269
269
  await app.register(async (authScope) => {
270
- await authScope.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
270
+ await authScope.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
271
271
  await authScope.register(projectListingMeRoute);
272
272
  });
273
273
  await app.ready();
@@ -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 {
@@ -512,18 +514,19 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
512
514
  * #6889 (SEC-02 of the #6876 security review) — make the checkout route's
513
515
  * posture observable. POST /v1/checkout/sessions admits a caller without
514
516
  * X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
515
- * unset, the route's same-origin redirect lock (#6877) answers 500
516
- * config-error to that caller, and the CORS and cookie-CSRF origin checks pin
517
- * to the `http://localhost:3000` config default. Both stay optional (OSS
518
- * standalone compat), so this only ever WARNS — once per unset variable, and
519
- * only in production. FRONTEND_URL is read from the raw environment because
520
- * `config.FRONTEND_URL` carries that localhost default.
517
+ * unset in production, the trusted-origin list (#7395) holds only
518
+ * AILK_TRUSTED_ORIGINS — with that unset too, CORS and cookie writes refuse
519
+ * every browser origin and the redirect lock (#6877) answers 500
520
+ * config-error to that caller. Both stay optional (OSS standalone compat), so
521
+ * this only ever WARNS — once per unset variable, and only in production.
522
+ * FRONTEND_URL is read from the raw environment because `config.FRONTEND_URL`
523
+ * carries a localhost default.
521
524
  */
522
525
  function warnOnUnsetProductionProtections(): void {
523
526
  if (config.NODE_ENV !== "production") return;
524
527
  if (!process.env["FRONTEND_URL"]) {
525
528
  logger.warn(
526
- "[startup] FRONTEND_URL not set in production — CORS/cookie origin checks are pinned to http://localhost:3000, and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
529
+ "[startup] FRONTEND_URL not set in production — CORS, cookie-authenticated writes and checkout redirects trust only AILK_TRUSTED_ORIGINS; with neither set, all three refuse every browser origin and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
527
530
  );
528
531
  }
529
532
  if (!process.env["OSS_API_INTERNAL_SECRET"]) {
@@ -614,6 +617,17 @@ export async function build() {
614
617
  trustProxy: true,
615
618
  });
616
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
+
617
631
  // Collect the route table this server registers (#5314).
618
632
  //
619
633
  // Installed here — after `Fastify()` and before the first `register()` — so
@@ -645,12 +659,16 @@ export async function build() {
645
659
  // never reach the client. Full detail still goes to pino at error level.
646
660
  registerErrorSerializer(server);
647
661
 
648
- // Register CORS — public read API + same-origin auth callers.
649
- // With FRONTEND_URL unset: reflect any origin in dev/test (zero-config
650
- // boot) but fail CLOSED in production — `origin: true` combined with
651
- // `credentials: true` would let any website read credentialed responses
652
- // (security review M1). `false` disables cross-origin access entirely;
653
- // same-origin and non-browser callers are unaffected.
662
+ // Register CORS — public read API + the trusted browser origins (#7395).
663
+ // `origin` is ALWAYS a copy of `config.TRUSTED_ORIGINS`, the frozen list
664
+ // config/index.ts resolves once at startup from FRONTEND_URL and
665
+ // AILK_TRUSTED_ORIGINS (no `*`, no wildcard host). @fastify/cors echoes a
666
+ // request's Origin only on an exact match against that array, and writes no
667
+ // allow-origin header otherwise; an empty list means no cross-origin caller.
668
+ // Never pass `true`, a function, a RegExp or a delegator here, and never set
669
+ // a per-route `cors` option: with `credentials: true`, reflecting any origin
670
+ // would let any website read credentialed responses (security review M1).
671
+ // Same-origin and non-browser callers are unaffected.
654
672
  //
655
673
  // `methods` is EXPLICIT because @fastify/cors defaults it to `GET,HEAD,POST`
656
674
  // (its own `defaultOptions`), which is narrower than the surface this server
@@ -666,7 +684,7 @@ export async function build() {
666
684
  // live route table on every run, so a route added in a NEW method fails there
667
685
  // rather than silently becoming unreachable from a browser.
668
686
  //
669
- // This widens WHICH METHODS the allowed origin may use, never WHO is allowed:
687
+ // This widens WHICH METHODS the allowed origins may use, never WHO is allowed:
670
688
  // `origin` and `credentials` are untouched.
671
689
  //
672
690
  // `allowedHeaders` is deliberately left unset: null means @fastify/cors
@@ -674,7 +692,7 @@ export async function build() {
674
692
  // and `content-type` pass without this file having to maintain a list that
675
693
  // silently drops a header a future route needs.
676
694
  await server.register(cors, {
677
- origin: config.FRONTEND_URL || config.NODE_ENV !== "production",
695
+ origin: [...config.TRUSTED_ORIGINS],
678
696
  credentials: true,
679
697
  methods: ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
680
698
  });
@@ -683,9 +701,16 @@ export async function build() {
683
701
  // limiter is in-memory by default, which suits a single Vercel function /
684
702
  // single-process dev server; multi-instance deployments will need a
685
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.
686
709
  await server.register(rateLimit, {
687
710
  max: CONTENT_RATE_LIMIT_UNAUTH,
688
711
  timeWindow: CONTENT_RATE_LIMIT_WINDOW,
712
+ keyGenerator: (req) =>
713
+ resolveRateLimitIdentity(req, visitorForwardKeyDigests).identity,
689
714
  });
690
715
 
691
716
  // Health check
@@ -726,7 +751,7 @@ export async function build() {
726
751
  // handlers. Cookie-first, Bearer-fallback; see middleware/auth.ts.
727
752
  await server.register(async (authScope) => {
728
753
  await authScope.register(authPreHandlerPlugin, {
729
- frontendOrigin: config.FRONTEND_URL,
754
+ trustedOrigins: config.TRUSTED_ORIGINS,
730
755
  });
731
756
  await authScope.register(tokenRoutes);
732
757
  await authScope.register(userRoutes);
@@ -1184,9 +1209,17 @@ export async function build() {
1184
1209
  // default (10/min, pre-#4730) rather than inheriting the wider
1185
1210
  // flows-sized default (#5111, DEF-004 — #4730's shared-default bump was
1186
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`.
1187
1218
  await server.register(async (leadsScope) => {
1188
1219
  leadsScope.register(rateLimitMiddleware, {
1189
1220
  perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1221
+ scope: "leads",
1222
+ trustedKeyDigests: visitorForwardKeyDigests,
1190
1223
  });
1191
1224
  await leadsScope.register(leadSubmitRoute);
1192
1225
  await leadsScope.register(leadSubscribeRoute);