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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -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__/leads-forwarded-rate-limit.test.ts +135 -0
- 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/__tests__/vercel-handler.test.ts +233 -12
- 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/lib/auth-email-budget.ts +3 -2
- 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/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- 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/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/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 +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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();
|
|
@@ -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
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
* only
|
|
520
|
-
*
|
|
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
|
|
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 +
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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);
|