create-ailk 0.18.0 → 0.19.1
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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The hub's sign-in continuation module (#8146 D3; spec AC-3(b), AC-4).
|
|
7
|
+
* Pure functions over `seal.ts`; no mocks.
|
|
8
|
+
*/
|
|
9
|
+
import { seal } from "../../../../../lib/hub-session/seal";
|
|
10
|
+
import {
|
|
11
|
+
CONTINUATION_REF_MAX,
|
|
12
|
+
CONTINUATION_TTL_SECONDS,
|
|
13
|
+
CONTINUE_PATH,
|
|
14
|
+
mintContinuation,
|
|
15
|
+
openContinuation,
|
|
16
|
+
} from "../continuation";
|
|
17
|
+
|
|
18
|
+
const SECRET = "k".repeat(43);
|
|
19
|
+
const T = 1_790_000_000_000;
|
|
20
|
+
|
|
21
|
+
const Q = new URLSearchParams({
|
|
22
|
+
response_type: "code",
|
|
23
|
+
client_id: "spoke_abc",
|
|
24
|
+
redirect_uri: "https://spoke.example.com/api/hub-session/callback",
|
|
25
|
+
state: "s".repeat(43),
|
|
26
|
+
code_challenge: "c".repeat(43),
|
|
27
|
+
code_challenge_method: "S256",
|
|
28
|
+
}).toString();
|
|
29
|
+
|
|
30
|
+
describe("constants", () => {
|
|
31
|
+
it("pins the path, the ref cap and the 600 s life", () => {
|
|
32
|
+
expect(CONTINUE_PATH).toBe("/api/auth/hub/continue");
|
|
33
|
+
expect(CONTINUATION_REF_MAX).toBe(4096);
|
|
34
|
+
expect(CONTINUATION_TTL_SECONDS).toBe(600);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("AC-4 — the continuation lives 600 s and round-trips", () => {
|
|
39
|
+
it("opens to the query it was minted from, up to the last millisecond", () => {
|
|
40
|
+
const ref = mintContinuation(Q, SECRET, T);
|
|
41
|
+
expect(openContinuation(ref, SECRET, T + 599_000)).toBe(Q);
|
|
42
|
+
expect(openContinuation(ref, SECRET, T + 599_999)).toBe(Q);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("is gone at exactly 600 s (exp <= now)", () => {
|
|
46
|
+
const ref = mintContinuation(Q, SECRET, T);
|
|
47
|
+
expect(openContinuation(ref, SECRET, T + 600_000)).toBeNull();
|
|
48
|
+
expect(openContinuation(ref, SECRET, T + 600_001)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("is base64url only, so no decode can change it", () => {
|
|
52
|
+
const ref = mintContinuation(Q, SECRET, T);
|
|
53
|
+
expect(ref).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
54
|
+
expect(decodeURIComponent(decodeURIComponent(ref))).toBe(ref);
|
|
55
|
+
expect(ref.length).toBeLessThan(CONTINUATION_REF_MAX);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("AC-3(b) — a ref that is not ours opens to null", () => {
|
|
60
|
+
const good = mintContinuation(Q, SECRET, T);
|
|
61
|
+
|
|
62
|
+
it("flips the first character", () => {
|
|
63
|
+
const flipped = (good[0] === "A" ? "B" : "A") + good.slice(1);
|
|
64
|
+
expect(openContinuation(flipped, SECRET, T)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("sealed under another secret", () => {
|
|
68
|
+
const other = mintContinuation(Q, "x".repeat(43), T);
|
|
69
|
+
expect(openContinuation(other, SECRET, T)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('a valid payload sealed with purpose "flow" and the SAME AAD (only the key differs)', () => {
|
|
73
|
+
const forged = seal(
|
|
74
|
+
{ v: 1, q: Q, exp: T + 600_000 },
|
|
75
|
+
SECRET,
|
|
76
|
+
"flow",
|
|
77
|
+
"hub-continue",
|
|
78
|
+
);
|
|
79
|
+
expect(openContinuation(forged, SECRET, T)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("a value sealed under another name never opens as a continuation", () => {
|
|
83
|
+
const forged = seal(
|
|
84
|
+
{ v: 1, q: Q, exp: T + 600_000 },
|
|
85
|
+
SECRET,
|
|
86
|
+
"continue",
|
|
87
|
+
"ailk-hub-flow",
|
|
88
|
+
);
|
|
89
|
+
expect(openContinuation(forged, SECRET, T)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("exp in the past", () => {
|
|
93
|
+
const expired = seal(
|
|
94
|
+
{ v: 1, q: Q, exp: T - 1 },
|
|
95
|
+
SECRET,
|
|
96
|
+
"continue",
|
|
97
|
+
"hub-continue",
|
|
98
|
+
);
|
|
99
|
+
expect(openContinuation(expired, SECRET, T)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("4097 characters", () => {
|
|
103
|
+
expect(openContinuation("A".repeat(4097), SECRET, T)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("a character outside base64url", () => {
|
|
107
|
+
expect(openContinuation(`${good}%`, SECRET, T)).toBeNull();
|
|
108
|
+
expect(openContinuation(`${good}?x=1`, SECRET, T)).toBeNull();
|
|
109
|
+
expect(openContinuation("", SECRET, T)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("the base64url of plain JSON, unsealed", () => {
|
|
113
|
+
const plain = Buffer.from(
|
|
114
|
+
JSON.stringify({ v: 1, q: Q, exp: T + 600_000 }),
|
|
115
|
+
).toString("base64url");
|
|
116
|
+
expect(openContinuation(plain, SECRET, T)).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each([
|
|
120
|
+
["a wrong version", { v: 2, q: Q, exp: T + 1000 }],
|
|
121
|
+
["an empty q", { v: 1, q: "", exp: T + 1000 }],
|
|
122
|
+
["a non-string q", { v: 1, q: 5, exp: T + 1000 }],
|
|
123
|
+
["a non-number exp", { v: 1, q: Q, exp: "later" }],
|
|
124
|
+
["a bare string", "hello"],
|
|
125
|
+
["null", null],
|
|
126
|
+
])("a sealed value with %s", (_name, value) => {
|
|
127
|
+
const ref = seal(value, SECRET, "continue", "hub-continue");
|
|
128
|
+
expect(openContinuation(ref, SECRET, T)).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("re-serializes a forged q so a CR/LF is percent-encoded and never throws", () => {
|
|
132
|
+
const ref = seal(
|
|
133
|
+
{ v: 1, q: "a=1\r\nX-Injected: 1", exp: T + 600_000 },
|
|
134
|
+
SECRET,
|
|
135
|
+
"continue",
|
|
136
|
+
"hub-continue",
|
|
137
|
+
);
|
|
138
|
+
const opened = openContinuation(ref, SECRET, T);
|
|
139
|
+
expect(opened).not.toBeNull();
|
|
140
|
+
expect(opened).not.toMatch(/[\r\n]/);
|
|
141
|
+
expect(() => new Headers({ Location: `/x?${opened}` })).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
// --- Mocks set up BEFORE imports ---
|
|
16
16
|
|
|
17
17
|
const mockGetSession = jest.fn();
|
|
18
|
+
const mockSecret = "k".repeat(43);
|
|
18
19
|
// eslint-disable-next-line no-restricted-syntax -- auth.ts is the Better Auth singleton (its adapter needs a live database); this stand-in for the session read is the repo-standard double for that boundary (see the sibling suites under app/[locale]/(authed)/account/__tests__).
|
|
19
20
|
jest.mock("../../../../../../auth", () => ({
|
|
20
21
|
__esModule: true,
|
|
21
22
|
auth: {
|
|
22
23
|
api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
|
|
23
24
|
},
|
|
25
|
+
resolveSecret: () => mockSecret,
|
|
24
26
|
}));
|
|
25
27
|
|
|
26
28
|
const mockCreateCode = jest.fn();
|
|
@@ -30,9 +32,11 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
30
32
|
}));
|
|
31
33
|
|
|
32
34
|
import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
|
|
35
|
+
import { openContinuation } from "../../continuation";
|
|
33
36
|
import { GET } from "../route";
|
|
34
37
|
|
|
35
38
|
const HUB = "https://hub.example.com";
|
|
39
|
+
const CONTINUE = "/api/auth/hub/continue";
|
|
36
40
|
const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
|
|
37
41
|
const CHALLENGE = "c".repeat(43);
|
|
38
42
|
const STATE = "s".repeat(43);
|
|
@@ -102,34 +106,82 @@ describe("a malformed query is a 400 and never a redirect", () => {
|
|
|
102
106
|
});
|
|
103
107
|
|
|
104
108
|
describe("no Better Auth session", () => {
|
|
105
|
-
|
|
109
|
+
/** The `next` value of the 303, and the ref inside it. */
|
|
110
|
+
function continuationOf(res: Response): { next: string; ref: string } {
|
|
111
|
+
const location = res.headers.get("location") ?? "";
|
|
112
|
+
const next = new URL(location, HUB).searchParams.get("next") ?? "";
|
|
113
|
+
return { next, ref: next.slice(`${CONTINUE}/`.length) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it("#8146 AC-1(b): 303 to /sign-in?next= followed by the encoded continue path and a sealed ref", async () => {
|
|
106
117
|
mockGetSession.mockResolvedValue(null);
|
|
107
118
|
const search = query();
|
|
108
119
|
|
|
109
120
|
const res = await GET(request(search));
|
|
110
121
|
|
|
111
122
|
expect(res.status).toBe(303);
|
|
112
|
-
const
|
|
113
|
-
expect(location).toBe(
|
|
114
|
-
`/sign-in?next=${encodeURIComponent(
|
|
123
|
+
const { next, ref } = continuationOf(res);
|
|
124
|
+
expect(res.headers.get("location")).toBe(
|
|
125
|
+
`/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
|
|
115
126
|
);
|
|
127
|
+
expect(next).toBe(`${CONTINUE}/${ref}`);
|
|
128
|
+
expect(openContinuation(ref, mockSecret)).toBe(search);
|
|
129
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
130
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
116
131
|
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
117
132
|
});
|
|
118
133
|
|
|
119
|
-
it("AC-
|
|
134
|
+
it("#8146 AC-1(b): the next value is a same-origin path the sign-in card keeps (resolveNextPath) and a second decode cannot change", async () => {
|
|
120
135
|
mockGetSession.mockResolvedValue(null);
|
|
121
136
|
|
|
122
137
|
const res = await GET(request(query()));
|
|
123
138
|
|
|
124
|
-
const next =
|
|
125
|
-
|
|
126
|
-
"next",
|
|
127
|
-
) ?? "";
|
|
128
|
-
expect(next.startsWith("/api/auth/hub/authorize?")).toBe(true);
|
|
139
|
+
const { next } = continuationOf(res);
|
|
140
|
+
expect(next.startsWith(`${CONTINUE}/`)).toBe(true);
|
|
129
141
|
// Unchanged — not the fallback — so Better Auth is handed it as the callback
|
|
130
142
|
// for Google and for the emailed link alike.
|
|
131
143
|
expect(resolveNextPath(next, "/account")).toBe(next);
|
|
132
144
|
expect(next).not.toMatch(/^https?:/);
|
|
145
|
+
// Better Auth decodes the callback a second time: nothing may change.
|
|
146
|
+
expect(next).toMatch(/^[A-Za-z0-9_/-]+$/);
|
|
147
|
+
expect(decodeURIComponent(decodeURIComponent(next))).toBe(next);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("#8146 AC-1(b): the ref holds the canonical six-key query — a reversed query with an extra key opens to the same string, without it", async () => {
|
|
151
|
+
mockGetSession.mockResolvedValue(null);
|
|
152
|
+
const canonical = query();
|
|
153
|
+
const reversed = [...new URLSearchParams(canonical).entries()]
|
|
154
|
+
.reverse()
|
|
155
|
+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
|
156
|
+
.join("&");
|
|
157
|
+
|
|
158
|
+
const res = await GET(request(`${reversed}&extra=1`));
|
|
159
|
+
|
|
160
|
+
expect(res.status).toBe(303);
|
|
161
|
+
const opened = openContinuation(continuationOf(res).ref, mockSecret);
|
|
162
|
+
expect(opened).toBe(canonical);
|
|
163
|
+
expect(opened).not.toContain("extra");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("#8146 AC-3(b): a redirect_uri whose ref would pass 4096 characters is a 400 with NO Location", async () => {
|
|
167
|
+
mockGetSession.mockResolvedValue(null);
|
|
168
|
+
const redirectUri = "https://spoke.example.com/".padEnd(2048, "/");
|
|
169
|
+
|
|
170
|
+
const res = await GET(request(query({ redirect_uri: redirectUri })));
|
|
171
|
+
|
|
172
|
+
expect(res.status).toBe(400);
|
|
173
|
+
expect(res.headers.get("location")).toBeNull();
|
|
174
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("#8146 AC-3(b): a 2048-character redirect_uri whose ref stays under the cap is a normal 303", async () => {
|
|
178
|
+
mockGetSession.mockResolvedValue(null);
|
|
179
|
+
const redirectUri = "https://spoke.example.com/".padEnd(2048, "a");
|
|
180
|
+
|
|
181
|
+
const res = await GET(request(query({ redirect_uri: redirectUri })));
|
|
182
|
+
|
|
183
|
+
expect(res.status).toBe(303);
|
|
184
|
+
expect(res.headers.get("location")).toMatch(/^\/sign-in\?next=/);
|
|
133
185
|
});
|
|
134
186
|
});
|
|
135
187
|
|
|
@@ -13,11 +13,15 @@
|
|
|
13
13
|
* &code_challenge=…&code_challenge_method=S256
|
|
14
14
|
*
|
|
15
15
|
* - a malformed query is a 400 and NEVER a redirect;
|
|
16
|
-
* - no Better Auth session:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* - no Better Auth session: the six validated keys are sealed into a short
|
|
17
|
+
* ref and the answer is a 303 to `/sign-in?next=<the same-origin path
|
|
18
|
+
* /api/auth/hub/continue/<ref>>` (#8146). `resolveNextPath` keeps that
|
|
19
|
+
* path and the existing sign-in card hands it to Better Auth as the
|
|
20
|
+
* callback for Google and for the emailed link alike; the continue route
|
|
21
|
+
* then returns the visitor here. This route's own URL is NOT the callback:
|
|
22
|
+
* Better Auth's magic-link verify decodes a callback twice, which turns the
|
|
23
|
+
* encoded `redirect_uri` inside it into a URL its origin check refuses. A
|
|
24
|
+
* ref longer than `CONTINUATION_REF_MAX` is a 400, never a redirect;
|
|
21
25
|
* - a session: the API mints a single-use code, and this route redirects to
|
|
22
26
|
* the REGISTERED redirect URI the API echoes back, with `code` and the
|
|
23
27
|
* same `state` — never to the query's own `redirect_uri` until the API has
|
|
@@ -40,7 +44,12 @@
|
|
|
40
44
|
import { hubSignIn } from "@working-theory/api-client";
|
|
41
45
|
import { hubAuthorizeQuerySchema } from "@working-theory/validation";
|
|
42
46
|
|
|
43
|
-
import { auth } from "../../../../../auth";
|
|
47
|
+
import { auth, resolveSecret } from "../../../../../auth";
|
|
48
|
+
import {
|
|
49
|
+
CONTINUATION_REF_MAX,
|
|
50
|
+
CONTINUE_PATH,
|
|
51
|
+
mintContinuation,
|
|
52
|
+
} from "../continuation";
|
|
44
53
|
|
|
45
54
|
/** The six query keys, each read exactly once. */
|
|
46
55
|
const QUERY_KEYS = [
|
|
@@ -106,7 +115,13 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
106
115
|
|
|
107
116
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
108
117
|
if (!session) {
|
|
109
|
-
|
|
118
|
+
// Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
|
|
119
|
+
// that did not pass the schema, and extra or reordered keys are dropped.
|
|
120
|
+
const canonical = new URLSearchParams();
|
|
121
|
+
for (const key of QUERY_KEYS) canonical.set(key, query[key]);
|
|
122
|
+
const ref = mintContinuation(canonical.toString(), resolveSecret());
|
|
123
|
+
if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
|
|
124
|
+
const next = `${CONTINUE_PATH}/${ref}`;
|
|
110
125
|
return see(`/sign-in?next=${encodeURIComponent(next)}`);
|
|
111
126
|
}
|
|
112
127
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's sign-in continuation (#8146, spec
|
|
3
|
+
* docs/specs/8146-email-link-sign-in-from-spec.md D2, D3).
|
|
4
|
+
*
|
|
5
|
+
* Better Auth's magic-link verify decodes `callbackURL` a second time, so a
|
|
6
|
+
* callback that holds an encoded `redirect_uri` is refused as
|
|
7
|
+
* INVALID_CALLBACK_URL. The authorize step therefore does not hand the sign-in
|
|
8
|
+
* page its own URL. It seals the validated authorize query into a short,
|
|
9
|
+
* opaque ref and hands over the same-origin path `/api/auth/hub/continue/<ref>`
|
|
10
|
+
* instead. A base64url string has no `%`, `?`, `&`, `=` or `:`, so no number of
|
|
11
|
+
* decodes changes it.
|
|
12
|
+
*
|
|
13
|
+
* Sealed (AES-256-GCM, key derived from the caller's secret under its own
|
|
14
|
+
* purpose), with its expiry inside. The ref is opaque but is NOT a secret: it
|
|
15
|
+
* carries what the cleartext `next` carried before (spec § 9 SEC-8146-1).
|
|
16
|
+
* Nothing here logs a ref.
|
|
17
|
+
*/
|
|
18
|
+
import { seal, unseal } from "../../../../lib/hub-session/seal";
|
|
19
|
+
|
|
20
|
+
/** The same-origin path a ref is appended to. */
|
|
21
|
+
export const CONTINUE_PATH = "/api/auth/hub/continue";
|
|
22
|
+
|
|
23
|
+
/** The longest ref the authorize step will mint, and the continue route open. */
|
|
24
|
+
export const CONTINUATION_REF_MAX = 4096;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 600 s, matching the spoke's flow cookie: a longer life is useless (the spoke
|
|
28
|
+
* refuses the callback once its flow expires) and it covers the 300 s emailed
|
|
29
|
+
* link plus time on the sign-in page.
|
|
30
|
+
*/
|
|
31
|
+
export const CONTINUATION_TTL_SECONDS = 600;
|
|
32
|
+
|
|
33
|
+
/** A fixed name: a value sealed for a cookie never opens here, nor the reverse. */
|
|
34
|
+
const AAD = "hub-continue";
|
|
35
|
+
|
|
36
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
37
|
+
|
|
38
|
+
/** `search` (the canonical authorize query, no leading `?`) as a sealed ref. */
|
|
39
|
+
export function mintContinuation(
|
|
40
|
+
search: string,
|
|
41
|
+
secret: string,
|
|
42
|
+
now: number = Date.now(),
|
|
43
|
+
): string {
|
|
44
|
+
return seal(
|
|
45
|
+
{ v: 1, q: search, exp: now + CONTINUATION_TTL_SECONDS * 1000 },
|
|
46
|
+
secret,
|
|
47
|
+
"continue",
|
|
48
|
+
AAD,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The authorize query a ref holds, or `null` when the ref is too long, is not
|
|
54
|
+
* base64url, does not open, is not the expected shape, or has expired.
|
|
55
|
+
*
|
|
56
|
+
* The query is re-serialized through `URLSearchParams`: for what
|
|
57
|
+
* `mintContinuation` wrote that is the same string, and for a `q` forged under
|
|
58
|
+
* a known secret it percent-encodes CR/LF so a `Location` can never be split
|
|
59
|
+
* and never throws (spec § 9 SEC-8146-7).
|
|
60
|
+
*/
|
|
61
|
+
export function openContinuation(
|
|
62
|
+
ref: string,
|
|
63
|
+
secret: string,
|
|
64
|
+
now: number = Date.now(),
|
|
65
|
+
): string | null {
|
|
66
|
+
if (ref.length > CONTINUATION_REF_MAX || !BASE64URL.test(ref)) return null;
|
|
67
|
+
const value = unseal(ref, secret, "continue", AAD);
|
|
68
|
+
if (typeof value !== "object" || value === null) return null;
|
|
69
|
+
const { v, q, exp } = value as Record<string, unknown>;
|
|
70
|
+
if (v !== 1 || typeof q !== "string" || q === "") return null;
|
|
71
|
+
if (typeof exp !== "number" || exp <= now) return null;
|
|
72
|
+
return new URLSearchParams(q).toString();
|
|
73
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for the hub's continue step (#8146, spec
|
|
7
|
+
* docs/specs/8146-email-link-sign-in-from-spec.md D4; AC-1(c), AC-3(b)).
|
|
8
|
+
*
|
|
9
|
+
* `auth.ts` (the Better Auth singleton: `resolveSecret`, the session read) and
|
|
10
|
+
* `@working-theory/api-client`'s `hubSignIn` are the external boundaries; both
|
|
11
|
+
* are mocked, exactly as the authorize suite does. The route's own decisions
|
|
12
|
+
* are under test: where a ref sends the visitor, and what it never does.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// --- Mocks set up BEFORE imports ---
|
|
16
|
+
|
|
17
|
+
const mockGetSession = jest.fn();
|
|
18
|
+
const mockSecret = "k".repeat(43);
|
|
19
|
+
// eslint-disable-next-line no-restricted-syntax -- auth.ts is the Better Auth singleton (its adapter needs a live database); this stand-in for the secret and the session read is the repo-standard double for that boundary (see the authorize suite beside this one).
|
|
20
|
+
jest.mock("../../../../../../../auth", () => ({
|
|
21
|
+
__esModule: true,
|
|
22
|
+
auth: {
|
|
23
|
+
api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
|
|
24
|
+
},
|
|
25
|
+
resolveSecret: () => mockSecret,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const mockCreateCode = jest.fn();
|
|
29
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
30
|
+
__esModule: true,
|
|
31
|
+
hubSignIn: { createCode: (...args: unknown[]) => mockCreateCode(...args) },
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import { seal } from "../../../../../../../lib/hub-session/seal";
|
|
35
|
+
import { GET as authorize } from "../../../authorize/route";
|
|
36
|
+
import { mintContinuation } from "../../../continuation";
|
|
37
|
+
import { GET } from "../route";
|
|
38
|
+
|
|
39
|
+
const HUB = "https://hub.example.com";
|
|
40
|
+
const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
|
|
41
|
+
const STATE = "s".repeat(43);
|
|
42
|
+
const CODE = "k".repeat(43);
|
|
43
|
+
|
|
44
|
+
const ORIGINAL_ENV = process.env;
|
|
45
|
+
|
|
46
|
+
const QUERY = new URLSearchParams({
|
|
47
|
+
response_type: "code",
|
|
48
|
+
client_id: "spoke_abc",
|
|
49
|
+
redirect_uri: SPOKE_CALLBACK,
|
|
50
|
+
state: STATE,
|
|
51
|
+
code_challenge: "c".repeat(43),
|
|
52
|
+
code_challenge_method: "S256",
|
|
53
|
+
}).toString();
|
|
54
|
+
|
|
55
|
+
function continueTo(ref: string): Promise<Response> {
|
|
56
|
+
return GET(new Request(`${HUB}/api/auth/hub/continue/${ref}`), {
|
|
57
|
+
params: Promise.resolve({ ref }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
jest.resetAllMocks();
|
|
63
|
+
process.env = { ...ORIGINAL_ENV, BETTER_AUTH_URL: HUB };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
process.env = ORIGINAL_ENV;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("AC-1(c) — a valid ref returns the visitor to the authorize step", () => {
|
|
71
|
+
it("answers 303 to /api/auth/hub/authorize with the same six-key query", async () => {
|
|
72
|
+
const res = await continueTo(mintContinuation(QUERY, mockSecret));
|
|
73
|
+
|
|
74
|
+
expect(res.status).toBe(303);
|
|
75
|
+
expect(res.headers.get("location")).toBe(
|
|
76
|
+
`/api/auth/hub/authorize?${QUERY}`,
|
|
77
|
+
);
|
|
78
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
79
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("chained into the authorize step with a session: 303 to the registered spoke URI with the code and the original state", async () => {
|
|
83
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
84
|
+
mockCreateCode.mockResolvedValue({
|
|
85
|
+
ok: true,
|
|
86
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const step = await continueTo(mintContinuation(QUERY, mockSecret));
|
|
90
|
+
const res = await authorize(
|
|
91
|
+
new Request(`${HUB}${step.headers.get("location")}`, {
|
|
92
|
+
headers: { cookie: "better-auth.session_token=tok" },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(res.status).toBe(303);
|
|
97
|
+
const target = new URL(res.headers.get("location") ?? "");
|
|
98
|
+
expect(`${target.origin}${target.pathname}`).toBe(SPOKE_CALLBACK);
|
|
99
|
+
expect(target.searchParams.get("code")).toBe(CODE);
|
|
100
|
+
expect(target.searchParams.get("state")).toBe(STATE);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("reads no session itself: the authorize step decides every code", async () => {
|
|
104
|
+
await continueTo(mintContinuation(QUERY, mockSecret));
|
|
105
|
+
expect(mockGetSession).not.toHaveBeenCalled();
|
|
106
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("AC-3(b) — any other ref answers 303 /account", () => {
|
|
111
|
+
const good = mintContinuation(QUERY, mockSecret);
|
|
112
|
+
const T = Date.now();
|
|
113
|
+
|
|
114
|
+
it.each([
|
|
115
|
+
[
|
|
116
|
+
"the first character flipped",
|
|
117
|
+
(good[0] === "A" ? "B" : "A") + good.slice(1),
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
"sealed under another secret",
|
|
121
|
+
mintContinuation(QUERY, "x".repeat(43)),
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
'a valid payload sealed with purpose "flow" and the SAME name',
|
|
125
|
+
seal(
|
|
126
|
+
{ v: 1, q: QUERY, exp: T + 600_000 },
|
|
127
|
+
mockSecret,
|
|
128
|
+
"flow",
|
|
129
|
+
"hub-continue",
|
|
130
|
+
),
|
|
131
|
+
],
|
|
132
|
+
["exp in the past", mintContinuation(QUERY, mockSecret, T - 601_000)],
|
|
133
|
+
["4097 characters", "A".repeat(4097)],
|
|
134
|
+
["a character outside base64url", `${good}%2Fevil`],
|
|
135
|
+
[
|
|
136
|
+
"plain base64url JSON, unsealed",
|
|
137
|
+
Buffer.from(
|
|
138
|
+
JSON.stringify({ v: 1, q: QUERY, exp: T + 600_000 }),
|
|
139
|
+
).toString("base64url"),
|
|
140
|
+
],
|
|
141
|
+
["an empty-looking ref", "-"],
|
|
142
|
+
])("%s", async (_name, ref) => {
|
|
143
|
+
const res = await continueTo(ref);
|
|
144
|
+
|
|
145
|
+
expect(res.status).toBe(303);
|
|
146
|
+
expect(res.headers.get("location")).toBe("/account");
|
|
147
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("a ref sealed under the test secret whose q holds CR/LF answers 303 without throwing, with no CR or LF in Location", async () => {
|
|
151
|
+
const ref = seal(
|
|
152
|
+
{ v: 1, q: "a=1\r\nX-Injected: 1", exp: T + 600_000 },
|
|
153
|
+
mockSecret,
|
|
154
|
+
"continue",
|
|
155
|
+
"hub-continue",
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const res = await continueTo(ref);
|
|
159
|
+
|
|
160
|
+
expect(res.status).toBe(303);
|
|
161
|
+
expect(res.headers.get("location")).not.toMatch(/[\r\n]/);
|
|
162
|
+
expect(res.headers.get("x-injected")).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("every Location is /account or starts with /api/auth/hub/authorize? — never `//` or a scheme", async () => {
|
|
166
|
+
const refs = [
|
|
167
|
+
good,
|
|
168
|
+
"garbage",
|
|
169
|
+
mintContinuation("redirect_uri=https://evil.example/x", mockSecret),
|
|
170
|
+
mintContinuation("//evil.example/x", mockSecret),
|
|
171
|
+
seal(
|
|
172
|
+
{ v: 1, q: "https://evil.example/x", exp: T + 600_000 },
|
|
173
|
+
mockSecret,
|
|
174
|
+
"continue",
|
|
175
|
+
"hub-continue",
|
|
176
|
+
),
|
|
177
|
+
];
|
|
178
|
+
for (const ref of refs) {
|
|
179
|
+
const location = (await continueTo(ref)).headers.get("location") ?? "";
|
|
180
|
+
expect(
|
|
181
|
+
location === "/account" ||
|
|
182
|
+
location.startsWith("/api/auth/hub/authorize?"),
|
|
183
|
+
).toBe(true);
|
|
184
|
+
expect(location.startsWith("//")).toBe(false);
|
|
185
|
+
expect(location).not.toMatch(/^[a-z][a-z0-9+.-]*:/i);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's sign-in continuation (#8146, spec
|
|
3
|
+
* docs/specs/8146-email-link-sign-in-from-spec.md D4).
|
|
4
|
+
*
|
|
5
|
+
* GET /api/auth/hub/continue/<ref>
|
|
6
|
+
*
|
|
7
|
+
* The authorize step, for a signed-out visitor, seals the validated authorize
|
|
8
|
+
* query into `<ref>` and sends the visitor to sign in with THIS path as the
|
|
9
|
+
* destination (Better Auth decodes a callback twice, so the path must hold
|
|
10
|
+
* nothing a decode can change). After sign-in — by the emailed link or by
|
|
11
|
+
* Google — the visitor lands here, and this route sends them back to the
|
|
12
|
+
* authorize step with the original query.
|
|
13
|
+
*
|
|
14
|
+
* - a valid, unexpired ref: 303 to `/api/auth/hub/authorize?<the query>`;
|
|
15
|
+
* - any other ref (forged, expired, replayed past its life, unreadable): 303
|
|
16
|
+
* to the hub's own `/account`. By then the spoke's flow cookie (600 s) has
|
|
17
|
+
* expired too, so returning to the spoke could not finish.
|
|
18
|
+
*
|
|
19
|
+
* Both targets are fixed relative paths: this route redirects nowhere else,
|
|
20
|
+
* and never to a URL carried in the ref. It reads no session — the authorize
|
|
21
|
+
* step does that and mints the code, so every decision about a code stays in
|
|
22
|
+
* one place. It logs nothing.
|
|
23
|
+
*/
|
|
24
|
+
import { resolveSecret } from "../../../../../../auth";
|
|
25
|
+
import { openContinuation } from "../../continuation";
|
|
26
|
+
|
|
27
|
+
/** Where an unreadable ref goes: the destination the hub's own sign-in uses. */
|
|
28
|
+
const FALLBACK_PATH = "/account";
|
|
29
|
+
|
|
30
|
+
const AUTHORIZE_PATH = "/api/auth/hub/authorize";
|
|
31
|
+
|
|
32
|
+
/** A 303 to a fixed same-origin path. */
|
|
33
|
+
function see(location: string): Response {
|
|
34
|
+
return new Response(null, {
|
|
35
|
+
status: 303,
|
|
36
|
+
headers: {
|
|
37
|
+
Location: location,
|
|
38
|
+
"Cache-Control": "no-store",
|
|
39
|
+
// The authorize query rides this URL; no page may leak it through a Referer.
|
|
40
|
+
"Referrer-Policy": "no-referrer",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect step, not a versioned API contract (spec 8146 §7)
|
|
46
|
+
export async function GET(
|
|
47
|
+
_request: Request,
|
|
48
|
+
{ params }: { params: Promise<{ ref: string }> },
|
|
49
|
+
): Promise<Response> {
|
|
50
|
+
const { ref } = await params;
|
|
51
|
+
const query = openContinuation(ref, resolveSecret());
|
|
52
|
+
return see(query === null ? FALLBACK_PATH : `${AUTHORIZE_PATH}?${query}`);
|
|
53
|
+
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* - Sitemap location declared
|
|
7
7
|
* - Optional path disallow via AILK_ROBOTS_DISALLOW env var
|
|
8
8
|
* (comma-separated paths, e.g. "/admin/,/private/")
|
|
9
|
+
* - STEALTH=true disallows every path for every crawler — layer 1 of the
|
|
10
|
+
* three-layer stealth posture (#884, #7374); it subsumes any
|
|
11
|
+
* AILK_ROBOTS_DISALLOW paths
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
@@ -14,10 +17,13 @@ export function GET(): Response {
|
|
|
14
17
|
const baseUrl = resolveBaseUrl();
|
|
15
18
|
const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
|
|
16
19
|
|
|
17
|
-
const disallowPaths =
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const disallowPaths =
|
|
21
|
+
process.env["STEALTH"] === "true"
|
|
22
|
+
? ["/"]
|
|
23
|
+
: disallowEnv
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((p) => p.trim())
|
|
26
|
+
.filter((p) => p.length > 0);
|
|
21
27
|
|
|
22
28
|
const lines = [
|
|
23
29
|
"User-agent: *",
|