create-ailk 0.11.1 → 0.11.2
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/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/package.json +1 -1
- package/templates/.env.example +13 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +21 -16
- package/templates/apps/web/app/[locale]/layout.tsx +57 -56
- package/templates/apps/web/auth.ts +73 -13
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
|
@@ -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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
48
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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]
|
|
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) =>
|
|
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, {
|
|
270
|
+
await authScope.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
271
271
|
await authScope.register(projectListingMeRoute);
|
|
272
272
|
});
|
|
273
273
|
await app.ready();
|
|
@@ -512,18 +512,19 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
|
|
|
512
512
|
* #6889 (SEC-02 of the #6876 security review) — make the checkout route's
|
|
513
513
|
* posture observable. POST /v1/checkout/sessions admits a caller without
|
|
514
514
|
* X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
|
|
515
|
-
* unset, the
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
* only
|
|
520
|
-
*
|
|
515
|
+
* unset in production, the trusted-origin list (#7395) holds only
|
|
516
|
+
* AILK_TRUSTED_ORIGINS — with that unset too, CORS and cookie writes refuse
|
|
517
|
+
* every browser origin and the redirect lock (#6877) answers 500
|
|
518
|
+
* config-error to that caller. Both stay optional (OSS standalone compat), so
|
|
519
|
+
* this only ever WARNS — once per unset variable, and only in production.
|
|
520
|
+
* FRONTEND_URL is read from the raw environment because `config.FRONTEND_URL`
|
|
521
|
+
* carries a localhost default.
|
|
521
522
|
*/
|
|
522
523
|
function warnOnUnsetProductionProtections(): void {
|
|
523
524
|
if (config.NODE_ENV !== "production") return;
|
|
524
525
|
if (!process.env["FRONTEND_URL"]) {
|
|
525
526
|
logger.warn(
|
|
526
|
-
"[startup] FRONTEND_URL not set in production — CORS
|
|
527
|
+
"[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
528
|
);
|
|
528
529
|
}
|
|
529
530
|
if (!process.env["OSS_API_INTERNAL_SECRET"]) {
|
|
@@ -645,12 +646,16 @@ export async function build() {
|
|
|
645
646
|
// never reach the client. Full detail still goes to pino at error level.
|
|
646
647
|
registerErrorSerializer(server);
|
|
647
648
|
|
|
648
|
-
// Register CORS — public read API +
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
649
|
+
// Register CORS — public read API + the trusted browser origins (#7395).
|
|
650
|
+
// `origin` is ALWAYS a copy of `config.TRUSTED_ORIGINS`, the frozen list
|
|
651
|
+
// config/index.ts resolves once at startup from FRONTEND_URL and
|
|
652
|
+
// AILK_TRUSTED_ORIGINS (no `*`, no wildcard host). @fastify/cors echoes a
|
|
653
|
+
// request's Origin only on an exact match against that array, and writes no
|
|
654
|
+
// allow-origin header otherwise; an empty list means no cross-origin caller.
|
|
655
|
+
// Never pass `true`, a function, a RegExp or a delegator here, and never set
|
|
656
|
+
// a per-route `cors` option: with `credentials: true`, reflecting any origin
|
|
657
|
+
// would let any website read credentialed responses (security review M1).
|
|
658
|
+
// Same-origin and non-browser callers are unaffected.
|
|
654
659
|
//
|
|
655
660
|
// `methods` is EXPLICIT because @fastify/cors defaults it to `GET,HEAD,POST`
|
|
656
661
|
// (its own `defaultOptions`), which is narrower than the surface this server
|
|
@@ -666,7 +671,7 @@ export async function build() {
|
|
|
666
671
|
// live route table on every run, so a route added in a NEW method fails there
|
|
667
672
|
// rather than silently becoming unreachable from a browser.
|
|
668
673
|
//
|
|
669
|
-
// This widens WHICH METHODS the allowed
|
|
674
|
+
// This widens WHICH METHODS the allowed origins may use, never WHO is allowed:
|
|
670
675
|
// `origin` and `credentials` are untouched.
|
|
671
676
|
//
|
|
672
677
|
// `allowedHeaders` is deliberately left unset: null means @fastify/cors
|
|
@@ -674,7 +679,7 @@ export async function build() {
|
|
|
674
679
|
// and `content-type` pass without this file having to maintain a list that
|
|
675
680
|
// silently drops a header a future route needs.
|
|
676
681
|
await server.register(cors, {
|
|
677
|
-
origin: config.
|
|
682
|
+
origin: [...config.TRUSTED_ORIGINS],
|
|
678
683
|
credentials: true,
|
|
679
684
|
methods: ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
|
680
685
|
});
|
|
@@ -726,7 +731,7 @@ export async function build() {
|
|
|
726
731
|
// handlers. Cookie-first, Bearer-fallback; see middleware/auth.ts.
|
|
727
732
|
await server.register(async (authScope) => {
|
|
728
733
|
await authScope.register(authPreHandlerPlugin, {
|
|
729
|
-
|
|
734
|
+
trustedOrigins: config.TRUSTED_ORIGINS,
|
|
730
735
|
});
|
|
731
736
|
await authScope.register(tokenRoutes);
|
|
732
737
|
await authScope.register(userRoutes);
|
|
@@ -48,7 +48,10 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
48
48
|
|
|
49
49
|
import { auth } from "../../auth";
|
|
50
50
|
import { GoogleAnalytics } from "../../components/google-analytics";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
SignedInNav,
|
|
53
|
+
type SignedInNavProps,
|
|
54
|
+
} from "../../components/signed-in-nav";
|
|
52
55
|
import { isAuthPath } from "../../lib/auth-routes";
|
|
53
56
|
import { assertEnabledLocale } from "../../lib/locale-guard";
|
|
54
57
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
@@ -83,10 +86,11 @@ type ThemeMode = "light" | "dark";
|
|
|
83
86
|
* and `flow` (open a StepFlow lightbox). The `flow` kind + Nav's
|
|
84
87
|
* `renderFlowTrigger` composer is Nav's ONE caller-composed seam (#4341): the
|
|
85
88
|
* caller receives the rendered trigger `<Button>` and decides what wraps it.
|
|
86
|
-
* The layout uses that seam WITHOUT a lightbox — `
|
|
87
|
-
* replaces both signed-in triggers (Account → a
|
|
88
|
-
* Sign out → `SignOutTrigger`), so this flow config
|
|
89
|
-
* has to satisfy the type: one minimal fields-step
|
|
89
|
+
* The layout uses that seam WITHOUT a lightbox — `SignedInNav`'s
|
|
90
|
+
* `renderFlowTrigger` replaces both signed-in triggers (Account → a
|
|
91
|
+
* non-navigating name label, Sign out → `SignOutTrigger`), so this flow config
|
|
92
|
+
* is NEVER rendered. It only has to satisfy the type: one minimal fields-step
|
|
93
|
+
* and a dead `action`.
|
|
90
94
|
*/
|
|
91
95
|
const NAV_CLIENT_ACTION_FLOW: NavActionFlow["flow"] = {
|
|
92
96
|
steps: [
|
|
@@ -314,7 +318,8 @@ export default async function LocaleLayout({
|
|
|
314
318
|
// the service shapes now roster both). Signed in → the user's name (a
|
|
315
319
|
// non-navigating label) + an outline-strong "Sign out"; both authored as
|
|
316
320
|
// `kind: "flow"` so Nav hands their triggers back through
|
|
317
|
-
// `renderFlowTrigger` (see NAV_CLIENT_ACTION_FLOW
|
|
321
|
+
// `renderFlowTrigger`, which `SignedInNav` owns (see NAV_CLIENT_ACTION_FLOW
|
|
322
|
+
// above).
|
|
318
323
|
const navActions: NavAction[] = session
|
|
319
324
|
? [
|
|
320
325
|
{ labelKey: "account", kind: "flow", flow: NAV_CLIENT_ACTION_FLOW },
|
|
@@ -331,15 +336,17 @@ export default async function LocaleLayout({
|
|
|
331
336
|
{ labelKey: "signUp", path: "/sign-up", variant: "primary" },
|
|
332
337
|
];
|
|
333
338
|
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
339
|
+
// A flow action needs `renderFlowTrigger`, a function — and this layout is a
|
|
340
|
+
// Server Component. Nav forwards that function into NavMobileMenu ("use
|
|
341
|
+
// client"), and React cannot serialize a function across the RSC boundary:
|
|
342
|
+
// passing it from here crashed EVERY signed-in page with "Functions cannot be
|
|
343
|
+
// passed directly to Client Components" (#7402). Nav's own
|
|
344
|
+
// `{...(renderFlowTrigger ? … : {})}` guard only skips the forward when the
|
|
345
|
+
// function is undefined; it never makes passing one legal. So a flow action
|
|
346
|
+
// routes the whole Nav through `SignedInNav` ("use client"), which defines
|
|
347
|
+
// `renderFlowTrigger` and imports `navItems` (icon components are functions
|
|
348
|
+
// too) on the client side. Without one, Nav stays a Server Component and
|
|
349
|
+
// nothing callable crosses.
|
|
343
350
|
const hasFlowAction = navActions.some((action) => action.kind === "flow");
|
|
344
351
|
|
|
345
352
|
// Shared brand mark injected into both Nav and Footer chrome slots. The
|
|
@@ -388,6 +395,33 @@ export default async function LocaleLayout({
|
|
|
388
395
|
// `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
|
|
389
396
|
const isAuthRoute = isAuthPath(pathname, enabledLocales);
|
|
390
397
|
|
|
398
|
+
// Every Nav prop that is safe to serialize — shared verbatim by the
|
|
399
|
+
// server-rendered Nav (signed out) and `SignedInNav` (signed in), which adds
|
|
400
|
+
// `navItems` and `renderFlowTrigger` on its own side of the RSC boundary.
|
|
401
|
+
const navProps: SignedInNavProps = {
|
|
402
|
+
locale,
|
|
403
|
+
labels: navLabels,
|
|
404
|
+
// Deck home (#5697): the two forwards that join #5426 D4 to a real deck.
|
|
405
|
+
// `sticky` pins the bar — MarketingShell renders {nav} as a plain child of
|
|
406
|
+
// the scroll container, so under `scroll-snap-type: y mandatory` the
|
|
407
|
+
// browser snaps slide 1 to the top on load and the unpinned bar is gone
|
|
408
|
+
// before the reader touches anything. `deckMorph` is what puts BrandLogo's
|
|
409
|
+
// `.ailk-deck-brand-mark` / `.ailk-deck-brand-wordmark` hooks in the
|
|
410
|
+
// document for the D4 CSS to key off; it requires both `logo` and
|
|
411
|
+
// `wordmark` (Nav.tsx:344), which are passed below. Scoped to the SAME
|
|
412
|
+
// `isDeckHome` gate MarketingShell's snap/deckHero use, so a non-deck
|
|
413
|
+
// route's render is byte-identical to before this branch existed.
|
|
414
|
+
...(isDeckHome ? { sticky: true, deckMorph: true } : {}),
|
|
415
|
+
siteName: SITE_NAME,
|
|
416
|
+
frame: "contained",
|
|
417
|
+
navAlignment: "right",
|
|
418
|
+
showThemeToggle: false,
|
|
419
|
+
logo: brandLogo,
|
|
420
|
+
wordmark: brandWordmark,
|
|
421
|
+
overflowLabel: navOverflowLabel,
|
|
422
|
+
actions: navActions,
|
|
423
|
+
};
|
|
424
|
+
|
|
391
425
|
// Placeholder social handles (#5469 D5): `{{COMPANY_HANDLE}}` is a
|
|
392
426
|
// create-ailk marker substituted at scaffold time
|
|
393
427
|
// (packages/create-ailk/src/programmatic.ts), so a customer's site never
|
|
@@ -582,47 +616,14 @@ export default async function LocaleLayout({
|
|
|
582
616
|
? { snap: "mandatory" as const, deckHero: true }
|
|
583
617
|
: {})}
|
|
584
618
|
nav={
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
// the top on load and the unpinned bar is gone before the
|
|
594
|
-
// reader touches anything. `deckMorph` is what puts BrandLogo's
|
|
595
|
-
// `.ailk-deck-brand-mark` / `.ailk-deck-brand-wordmark` hooks in
|
|
596
|
-
// the document for the D4 CSS to key off; it requires both
|
|
597
|
-
// `logo` and `wordmark` (Nav.tsx:344), which the layout already
|
|
598
|
-
// passes above. Scoped to the SAME `isDeckHome` gate
|
|
599
|
-
// MarketingShell's snap/deckHero use, so a non-deck route's
|
|
600
|
-
// render is byte-identical to before this branch existed.
|
|
601
|
-
{...(isDeckHome ? { sticky: true, deckMorph: true } : {})}
|
|
602
|
-
siteName={SITE_NAME}
|
|
603
|
-
frame="contained"
|
|
604
|
-
navAlignment="right"
|
|
605
|
-
showThemeToggle={false}
|
|
606
|
-
logo={brandLogo}
|
|
607
|
-
wordmark={brandWordmark}
|
|
608
|
-
overflowLabel={navOverflowLabel}
|
|
609
|
-
actions={navActions}
|
|
610
|
-
{...(hasFlowAction
|
|
611
|
-
? {
|
|
612
|
-
renderFlowTrigger: (action, trigger) =>
|
|
613
|
-
action.labelKey === "signOut" ? (
|
|
614
|
-
<SignOutTrigger>{trigger}</SignOutTrigger>
|
|
615
|
-
) : (
|
|
616
|
-
<span
|
|
617
|
-
className="text-body-sm text-(--color-text-muted)"
|
|
618
|
-
data-testid="nav-account-name"
|
|
619
|
-
>
|
|
620
|
-
{navLabels.items.account}
|
|
621
|
-
</span>
|
|
622
|
-
),
|
|
623
|
-
}
|
|
624
|
-
: {})}
|
|
625
|
-
/>
|
|
619
|
+
hasFlowAction ? (
|
|
620
|
+
<SignedInNav {...navProps} />
|
|
621
|
+
) : (
|
|
622
|
+
<Nav
|
|
623
|
+
{...navProps}
|
|
624
|
+
navItems={siteChrome.nav === "mark-only" ? [] : navItems}
|
|
625
|
+
/>
|
|
626
|
+
)
|
|
626
627
|
}
|
|
627
628
|
footer={
|
|
628
629
|
<Footer
|