create-ailk 0.14.0 → 0.16.0
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/README.md +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's browser step of "sign in through the hub" (#7894, spec
|
|
3
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md D2, D4).
|
|
4
|
+
*
|
|
5
|
+
* A spoke redirects a visitor here; this route is the one place the person
|
|
6
|
+
* must be signed in on the HUB'S OWN origin, because that is where the Better
|
|
7
|
+
* Auth session cookie lives. It owns no stored artifact: codes and credentials
|
|
8
|
+
* live behind `apps/api` (`POST /v1/hub-sign-in/codes`), reached through
|
|
9
|
+
* `@working-theory/api-client` with the visitor's own cookie.
|
|
10
|
+
*
|
|
11
|
+
* GET /api/auth/hub/authorize
|
|
12
|
+
* ?response_type=code&client_id=…&redirect_uri=…&state=…
|
|
13
|
+
* &code_challenge=…&code_challenge_method=S256
|
|
14
|
+
*
|
|
15
|
+
* - a malformed query is a 400 and NEVER a redirect;
|
|
16
|
+
* - no Better Auth session: 303 to `/sign-in?next=<this path and query>` —
|
|
17
|
+
* a same-origin path, so `resolveNextPath` keeps it and the existing
|
|
18
|
+
* sign-in card hands it to Better Auth as the callback for Google and for
|
|
19
|
+
* the emailed link alike (the hub's own `magicLink` sends the link, and it
|
|
20
|
+
* returns through this step);
|
|
21
|
+
* - a session: the API mints a single-use code, and this route redirects to
|
|
22
|
+
* the REGISTERED redirect URI the API echoes back, with `code` and the
|
|
23
|
+
* same `state` — never to the query's own `redirect_uri` until the API has
|
|
24
|
+
* confirmed it is the client's;
|
|
25
|
+
* - a person whose email is not verified (403 `email_unverified`) goes to the
|
|
26
|
+
* hub's own `/account/settings`, whose For-you item says how to verify —
|
|
27
|
+
* never to the spoke;
|
|
28
|
+
* - any other refusal is a 400 with `{ "error": "invalid_request" }` and no
|
|
29
|
+
* visitor copy: only a misconfigured client reaches it.
|
|
30
|
+
*
|
|
31
|
+
* `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
|
|
32
|
+
* (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
|
|
33
|
+
* origin, exactly as the emailed link does.
|
|
34
|
+
*
|
|
35
|
+
* This is the second api-shaped route under `apps/web/app/api/auth/`, beside
|
|
36
|
+
* Better Auth's catch-all; it sits under `app/api/auth`, a spoke removal seed
|
|
37
|
+
* (spec 7426 D4), so no spoke ever carries it. Nothing here logs a code, a
|
|
38
|
+
* state, a challenge or a cookie.
|
|
39
|
+
*/
|
|
40
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
41
|
+
import { hubAuthorizeQuerySchema } from "@working-theory/validation";
|
|
42
|
+
|
|
43
|
+
import { auth } from "../../../../../auth";
|
|
44
|
+
|
|
45
|
+
/** The six query keys, each read exactly once. */
|
|
46
|
+
const QUERY_KEYS = [
|
|
47
|
+
"response_type",
|
|
48
|
+
"client_id",
|
|
49
|
+
"redirect_uri",
|
|
50
|
+
"state",
|
|
51
|
+
"code_challenge",
|
|
52
|
+
"code_challenge_method",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** Where an unverified person is sent to verify: the hub's own account page. */
|
|
56
|
+
const VERIFY_EMAIL_PATH = "/account/settings";
|
|
57
|
+
|
|
58
|
+
function json(status: number, error: string): Response {
|
|
59
|
+
return new Response(JSON.stringify({ error }), {
|
|
60
|
+
status,
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"Cache-Control": "no-store",
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A 303 whose target may be a same-origin path or an absolute spoke URL. */
|
|
69
|
+
function see(location: string): Response {
|
|
70
|
+
return new Response(null, {
|
|
71
|
+
status: 303,
|
|
72
|
+
headers: {
|
|
73
|
+
Location: location,
|
|
74
|
+
"Cache-Control": "no-store",
|
|
75
|
+
// The code rides this URL; no page may leak it through a Referer.
|
|
76
|
+
"Referrer-Policy": "no-referrer",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The pinned origin of the hub's own web app, or `null` when unset/invalid. */
|
|
82
|
+
function pinnedOrigin(): string | null {
|
|
83
|
+
const raw = process.env.BETTER_AUTH_URL;
|
|
84
|
+
if (!raw) return null;
|
|
85
|
+
try {
|
|
86
|
+
return new URL(raw).origin;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
93
|
+
export async function GET(request: Request): Promise<Response> {
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
|
|
96
|
+
// Each declared key exactly once: a repeated key is ambiguous, so 400.
|
|
97
|
+
const raw: Record<string, string | undefined> = {};
|
|
98
|
+
for (const key of QUERY_KEYS) {
|
|
99
|
+
const values = url.searchParams.getAll(key);
|
|
100
|
+
if (values.length > 1) return json(400, "invalid_request");
|
|
101
|
+
raw[key] = values[0];
|
|
102
|
+
}
|
|
103
|
+
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
104
|
+
if (!parsed.success) return json(400, "invalid_request");
|
|
105
|
+
const query = parsed.data;
|
|
106
|
+
|
|
107
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
108
|
+
if (!session) {
|
|
109
|
+
const next = `${url.pathname}${url.search}`;
|
|
110
|
+
return see(`/sign-in?next=${encodeURIComponent(next)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const origin = pinnedOrigin();
|
|
114
|
+
if (origin === null) return json(503, "temporarily_unavailable");
|
|
115
|
+
|
|
116
|
+
let result: Awaited<ReturnType<typeof hubSignIn.createCode>>;
|
|
117
|
+
try {
|
|
118
|
+
result = await hubSignIn.createCode(
|
|
119
|
+
{
|
|
120
|
+
clientId: query.client_id,
|
|
121
|
+
redirectUri: query.redirect_uri,
|
|
122
|
+
codeChallenge: query.code_challenge,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
headers: {
|
|
126
|
+
cookie: request.headers.get("cookie") ?? "",
|
|
127
|
+
origin,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
return json(503, "temporarily_unavailable");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (result.ok) {
|
|
136
|
+
// Redirect to the URI the API confirmed is registered for this client.
|
|
137
|
+
let target: URL;
|
|
138
|
+
try {
|
|
139
|
+
target = new URL(result.body.redirectUri);
|
|
140
|
+
} catch {
|
|
141
|
+
// A registered URI is validated at registration; an unparseable echo is
|
|
142
|
+
// a server fault, never something to redirect to.
|
|
143
|
+
return json(503, "temporarily_unavailable");
|
|
144
|
+
}
|
|
145
|
+
target.searchParams.set("code", result.body.code);
|
|
146
|
+
target.searchParams.set("state", query.state);
|
|
147
|
+
return see(target.toString());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const code = (result.body as { error?: { code?: unknown } } | null)?.error
|
|
151
|
+
?.code;
|
|
152
|
+
if (result.status === 403 && code === "email_unverified") {
|
|
153
|
+
return see(VERIFY_EMAIL_PATH);
|
|
154
|
+
}
|
|
155
|
+
return json(400, "invalid_request");
|
|
156
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The spoke's three hub-session routes (#7894 D2, D7; spec
|
|
7
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md AC-3, AC-5).
|
|
8
|
+
*
|
|
9
|
+
* The api-client's `hubSignIn` (the hub API) is the one external boundary
|
|
10
|
+
* mocked; the seal, the cookies and the routes' decisions are real. Covers:
|
|
11
|
+
* the authorize redirect (PKCE S256, a state, the configured redirect URI —
|
|
12
|
+
* never one built from `Host`), the callback's state check and its exchange,
|
|
13
|
+
* a callback that BURNS a code it cannot complete (AC-5), and a sign-out that
|
|
14
|
+
* refuses a cross-site request and revokes nothing (AC-5). Each route is a 404
|
|
15
|
+
* unless the web seam's `SIGN_IN_PROVIDER` is `"hub"` (AC-9).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
const mockExchange = jest.fn();
|
|
21
|
+
const mockRevoke = jest.fn();
|
|
22
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
hubSignIn: {
|
|
25
|
+
exchange: (...args: unknown[]) => mockExchange(...args),
|
|
26
|
+
revoke: (...args: unknown[]) => mockRevoke(...args),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const mockProvider: { value: "site" | "hub" | "none" } = { value: "hub" };
|
|
31
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the sign-in provider is a generated constant (#7894 D8), not a runtime input; the seam IS the boundary between a hub and a hub-mode spoke build, and no env or HTTP surface selects it.
|
|
32
|
+
jest.mock("../../../../lib/topology.generated", () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
get SIGN_IN_PROVIDER() {
|
|
35
|
+
return mockProvider.value;
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { seal } from "../../../../lib/hub-session/seal";
|
|
40
|
+
import { GET as callback } from "../callback/route";
|
|
41
|
+
import { GET as signIn } from "../sign-in/route";
|
|
42
|
+
import { POST as signOut } from "../sign-out/route";
|
|
43
|
+
|
|
44
|
+
const ENV = process.env;
|
|
45
|
+
const SITE = "http://127.0.0.1:3100";
|
|
46
|
+
const HUB = "http://127.0.0.1:4100";
|
|
47
|
+
const SESSION_SECRET = "s".repeat(43);
|
|
48
|
+
const CLIENT = { clientId: "spoke_0001", clientSecret: `ailk_hcs_${"c".repeat(43)}` };
|
|
49
|
+
const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
|
|
50
|
+
const CODE = "k".repeat(43);
|
|
51
|
+
const USER = {
|
|
52
|
+
id: "user_1",
|
|
53
|
+
name: "Ada Lovelace",
|
|
54
|
+
email: "ada@example.com",
|
|
55
|
+
emailVerified: true,
|
|
56
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function configure(overrides: Record<string, string | undefined> = {}): void {
|
|
60
|
+
process.env = {
|
|
61
|
+
...ENV,
|
|
62
|
+
AILK_HUB_SIGN_IN_URL: HUB,
|
|
63
|
+
AILK_HUB_CLIENT_ID: CLIENT.clientId,
|
|
64
|
+
AILK_HUB_CLIENT_SECRET: CLIENT.clientSecret,
|
|
65
|
+
AILK_HUB_SESSION_SECRET: SESSION_SECRET,
|
|
66
|
+
NEXT_PUBLIC_BASE_URL: SITE,
|
|
67
|
+
};
|
|
68
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
69
|
+
if (value === undefined) delete process.env[key];
|
|
70
|
+
else process.env[key] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Each `Set-Cookie` the response carries. */
|
|
75
|
+
function setCookies(res: Response): string[] {
|
|
76
|
+
return res.headers.getSetCookie();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `name=value` of the cookie `name` in `res`'s `Set-Cookie` headers. */
|
|
80
|
+
function cookieValue(res: Response, name: string): string | undefined {
|
|
81
|
+
const line = setCookies(res).find((c) => c.startsWith(`${name}=`));
|
|
82
|
+
return line?.slice(name.length + 1).split(";")[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Start a sign-in and return the flow cookie and the authorize URL. */
|
|
86
|
+
function start(next = "/account"): { flow: string; authorize: URL; res: Response } {
|
|
87
|
+
const res = signIn(
|
|
88
|
+
new Request(`${SITE}/api/hub-session/sign-in?next=${encodeURIComponent(next)}`, {
|
|
89
|
+
// A Host unlike the site's: the redirect URI must not follow it.
|
|
90
|
+
headers: { host: "attacker.example.com" },
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const flow = cookieValue(res, "ailk-hub-flow");
|
|
94
|
+
if (flow === undefined) throw new Error("no flow cookie");
|
|
95
|
+
return { flow, authorize: new URL(res.headers.get("location") ?? ""), res };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function callbackRequest(query: string, cookie?: string): Request {
|
|
99
|
+
return new Request(`${SITE}/api/hub-session/callback?${query}`, {
|
|
100
|
+
headers: cookie === undefined ? {} : { cookie },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
configure();
|
|
106
|
+
mockProvider.value = "hub";
|
|
107
|
+
mockExchange.mockReset();
|
|
108
|
+
mockRevoke.mockReset().mockResolvedValue({ ok: true, body: { revoked: true } });
|
|
109
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
110
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
jest.restoreAllMocks();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
process.env = ENV;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("GET /api/hub-session/sign-in", () => {
|
|
122
|
+
it("303s to the hub's authorize step with PKCE S256, a state and the configured callback, and sets the flow cookie", () => {
|
|
123
|
+
const { authorize, res } = start();
|
|
124
|
+
expect(res.status).toBe(303);
|
|
125
|
+
expect(`${authorize.origin}${authorize.pathname}`).toBe(`${HUB}/api/auth/hub/authorize`);
|
|
126
|
+
const q = authorize.searchParams;
|
|
127
|
+
expect(q.get("response_type")).toBe("code");
|
|
128
|
+
expect(q.get("client_id")).toBe(CLIENT.clientId);
|
|
129
|
+
expect(q.get("redirect_uri")).toBe(`${SITE}/api/hub-session/callback`);
|
|
130
|
+
expect(q.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
131
|
+
expect(q.get("code_challenge_method")).toBe("S256");
|
|
132
|
+
expect(q.get("state")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
133
|
+
const flow = setCookies(res).find((c) => c.startsWith("ailk-hub-flow="));
|
|
134
|
+
expect(flow).toMatch(/; HttpOnly/);
|
|
135
|
+
expect(flow).toMatch(/; SameSite=Lax/);
|
|
136
|
+
expect(flow).toMatch(/; Max-Age=600/);
|
|
137
|
+
expect(flow).not.toMatch(/Secure/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("names the __Host- cookie, Secure, on an https site", () => {
|
|
141
|
+
configure({ NEXT_PUBLIC_BASE_URL: "https://spoke.example.com" });
|
|
142
|
+
const res = signIn(new Request("https://spoke.example.com/api/hub-session/sign-in"));
|
|
143
|
+
const flow = setCookies(res).find((c) => c.startsWith("__Host-ailk-hub-flow="));
|
|
144
|
+
expect(flow).toMatch(/; Secure$/);
|
|
145
|
+
expect(new URL(res.headers.get("location") ?? "").searchParams.get("redirect_uri")).toBe(
|
|
146
|
+
"https://spoke.example.com/api/hub-session/callback",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("with no configuration 303s to /account and sets no cookie", () => {
|
|
151
|
+
configure({ AILK_HUB_CLIENT_SECRET: undefined });
|
|
152
|
+
const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?next=/account`));
|
|
153
|
+
expect(res.status).toBe(303);
|
|
154
|
+
expect(res.headers.get("location")).toBe("/account");
|
|
155
|
+
expect(setCookies(res)).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it.each(["site", "none"] as const)("is 404 when the provider is %s", (provider) => {
|
|
159
|
+
mockProvider.value = provider;
|
|
160
|
+
expect(signIn(new Request(`${SITE}/api/hub-session/sign-in`)).status).toBe(404);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("GET /api/hub-session/callback", () => {
|
|
165
|
+
function exchangeOk(): void {
|
|
166
|
+
mockExchange.mockResolvedValue({
|
|
167
|
+
ok: true,
|
|
168
|
+
body: { accessToken: CREDENTIAL, tokenType: "Bearer", expiresIn: 86400, user: USER },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("exchanges the code with the verifier whose S256 is the challenge, seals the session and lands on next", async () => {
|
|
173
|
+
exchangeOk();
|
|
174
|
+
const { flow, authorize } = start("/fr/account/inbox");
|
|
175
|
+
const state = authorize.searchParams.get("state") ?? "";
|
|
176
|
+
const res = await callback(
|
|
177
|
+
callbackRequest(`code=${CODE}&state=${state}`, `ailk-hub-flow=${flow}`),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(res.status).toBe(303);
|
|
181
|
+
expect(res.headers.get("location")).toBe("/fr/account/inbox");
|
|
182
|
+
expect(mockExchange).toHaveBeenCalledTimes(1);
|
|
183
|
+
const [auth, body] = mockExchange.mock.calls[0] as [
|
|
184
|
+
typeof CLIENT,
|
|
185
|
+
{ code: string; redirectUri: string; codeVerifier: string },
|
|
186
|
+
];
|
|
187
|
+
expect(auth).toEqual(CLIENT);
|
|
188
|
+
expect(body.code).toBe(CODE);
|
|
189
|
+
expect(body.redirectUri).toBe(`${SITE}/api/hub-session/callback`);
|
|
190
|
+
expect(createHash("sha256").update(body.codeVerifier).digest("base64url")).toBe(
|
|
191
|
+
authorize.searchParams.get("code_challenge"),
|
|
192
|
+
);
|
|
193
|
+
const session = setCookies(res).find((c) => c.startsWith("ailk-hub-session="));
|
|
194
|
+
expect(session).toMatch(/; HttpOnly/);
|
|
195
|
+
expect(session).toMatch(/; Max-Age=86400/);
|
|
196
|
+
// The flow cookie is cleared, and the credential never appears in clear.
|
|
197
|
+
expect(setCookies(res).find((c) => c.startsWith("ailk-hub-flow="))).toMatch(/Max-Age=0/);
|
|
198
|
+
expect(setCookies(res).join("\n")).not.toContain(CREDENTIAL);
|
|
199
|
+
expect(mockRevoke).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("lands on the flow cookie's next, never a next in the callback's query", async () => {
|
|
203
|
+
exchangeOk();
|
|
204
|
+
const { flow, authorize } = start("/account");
|
|
205
|
+
const state = authorize.searchParams.get("state") ?? "";
|
|
206
|
+
const res = await callback(
|
|
207
|
+
callbackRequest(`code=${CODE}&state=${state}&next=%2F%2Fevil.example.com`, `ailk-hub-flow=${flow}`),
|
|
208
|
+
);
|
|
209
|
+
expect(res.headers.get("location")).toBe("/account");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("AC-5: without its flow cookie, burns the code at the hub and exchanges nothing", async () => {
|
|
213
|
+
const res = await callback(callbackRequest(`code=${CODE}&state=${"s".repeat(43)}`));
|
|
214
|
+
expect(res.status).toBe(303);
|
|
215
|
+
expect(res.headers.get("location")).toBe("/account");
|
|
216
|
+
expect(mockExchange).not.toHaveBeenCalled();
|
|
217
|
+
expect(mockRevoke).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(mockRevoke).toHaveBeenCalledWith(CLIENT, { code: CODE });
|
|
219
|
+
expect(setCookies(res).some((c) => c.startsWith("ailk-hub-session="))).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("burns the code on a state mismatch, a hub error, an expired flow and a failed exchange", async () => {
|
|
223
|
+
const { flow, authorize } = start();
|
|
224
|
+
const state = authorize.searchParams.get("state") ?? "";
|
|
225
|
+
const cookie = `ailk-hub-flow=${flow}`;
|
|
226
|
+
|
|
227
|
+
await callback(callbackRequest(`code=${CODE}&state=${"x".repeat(43)}`, cookie));
|
|
228
|
+
await callback(callbackRequest(`code=${CODE}&state=${state}&error=access_denied`, cookie));
|
|
229
|
+
const expired = seal(
|
|
230
|
+
{ v: 1, state, verifier: "v".repeat(43), next: "/account", expiresAt: Date.now() - 1 },
|
|
231
|
+
SESSION_SECRET,
|
|
232
|
+
"flow",
|
|
233
|
+
"ailk-hub-flow",
|
|
234
|
+
);
|
|
235
|
+
await callback(callbackRequest(`code=${CODE}&state=${state}`, `ailk-hub-flow=${expired}`));
|
|
236
|
+
expect(mockExchange).not.toHaveBeenCalled();
|
|
237
|
+
|
|
238
|
+
mockExchange.mockResolvedValue({ ok: false, status: 400, body: { error: "invalid_grant" } });
|
|
239
|
+
const failed = await callback(callbackRequest(`code=${CODE}&state=${state}`, cookie));
|
|
240
|
+
expect(failed.headers.get("location")).toBe("/account");
|
|
241
|
+
expect(mockRevoke).toHaveBeenCalledTimes(4);
|
|
242
|
+
for (const call of mockRevoke.mock.calls) expect(call).toEqual([CLIENT, { code: CODE }]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it.each([[0], [86_401], [Number.NaN], [undefined]])(
|
|
246
|
+
"refuses a token answer whose expiresIn is %s, setting no session",
|
|
247
|
+
async (expiresIn) => {
|
|
248
|
+
mockExchange.mockResolvedValue({
|
|
249
|
+
ok: true,
|
|
250
|
+
body: { accessToken: CREDENTIAL, tokenType: "Bearer", expiresIn, user: USER },
|
|
251
|
+
});
|
|
252
|
+
const { flow, authorize } = start();
|
|
253
|
+
const state = authorize.searchParams.get("state") ?? "";
|
|
254
|
+
const res = await callback(callbackRequest(`code=${CODE}&state=${state}`, `ailk-hub-flow=${flow}`));
|
|
255
|
+
expect(res.headers.get("location")).toBe("/account");
|
|
256
|
+
expect(setCookies(res).some((c) => c.startsWith("ailk-hub-session="))).toBe(false);
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
it("never logs the query string, the code or the state", async () => {
|
|
261
|
+
const warn = jest.spyOn(console, "warn");
|
|
262
|
+
await callback(callbackRequest(`code=${CODE}&state=${"s".repeat(43)}`));
|
|
263
|
+
const logged = warn.mock.calls.flat().join(" ");
|
|
264
|
+
expect(logged).toContain("reason=no_flow");
|
|
265
|
+
expect(logged).not.toContain(CODE);
|
|
266
|
+
expect(logged).not.toContain("s".repeat(43));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("does not try to burn a malformed code", async () => {
|
|
270
|
+
await callback(callbackRequest("code=not-a-code&state=x"));
|
|
271
|
+
expect(mockRevoke).not.toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("is 404 unless the provider is hub", async () => {
|
|
275
|
+
mockProvider.value = "none";
|
|
276
|
+
const res = await callback(callbackRequest(`code=${CODE}&state=x`));
|
|
277
|
+
expect(res.status).toBe(404);
|
|
278
|
+
expect(mockRevoke).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("POST /api/hub-session/sign-out", () => {
|
|
283
|
+
function sessionCookie(): string {
|
|
284
|
+
const sealed = seal(
|
|
285
|
+
{ v: 1, accessToken: CREDENTIAL, expiresAt: Date.now() + 60_000, user: USER },
|
|
286
|
+
SESSION_SECRET,
|
|
287
|
+
"session",
|
|
288
|
+
"ailk-hub-session",
|
|
289
|
+
);
|
|
290
|
+
return `ailk-hub-session=${sealed}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function post(headers: Record<string, string>): Promise<Response> {
|
|
294
|
+
return signOut(
|
|
295
|
+
new Request(`${SITE}/api/hub-session/sign-out`, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { cookie: sessionCookie(), ...headers },
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
it("same-origin: revokes the credential at the hub, clears the cookie and answers 204", async () => {
|
|
303
|
+
const res = await post({ "sec-fetch-site": "same-origin", origin: SITE });
|
|
304
|
+
expect(res.status).toBe(204);
|
|
305
|
+
expect(mockRevoke).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(mockRevoke).toHaveBeenCalledWith(CLIENT, { accessToken: CREDENTIAL });
|
|
307
|
+
expect(setCookies(res)).toEqual([
|
|
308
|
+
"ailk-hub-session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("AC-5: refuses Sec-Fetch-Site cross-site with 403, revoking nothing", async () => {
|
|
313
|
+
const res = await post({ "sec-fetch-site": "cross-site" });
|
|
314
|
+
expect(res.status).toBe(403);
|
|
315
|
+
expect(mockRevoke).not.toHaveBeenCalled();
|
|
316
|
+
expect(setCookies(res)).toEqual([]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("AC-5: refuses a foreign Origin with 403, revoking nothing", async () => {
|
|
320
|
+
const res = await post({ origin: "https://evil.example.com" });
|
|
321
|
+
expect(res.status).toBe(403);
|
|
322
|
+
expect(mockRevoke).not.toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("refuses a request carrying neither header", async () => {
|
|
326
|
+
expect((await post({})).status).toBe(403);
|
|
327
|
+
expect(mockRevoke).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("still signs out when the hub cannot be reached", async () => {
|
|
331
|
+
mockRevoke.mockRejectedValue(new Error("network"));
|
|
332
|
+
const res = await post({ "sec-fetch-site": "same-origin" });
|
|
333
|
+
expect(res.status).toBe(204);
|
|
334
|
+
expect(setCookies(res)[0]).toMatch(/Max-Age=0/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("is 404 unless the provider is hub", async () => {
|
|
338
|
+
mockProvider.value = "site";
|
|
339
|
+
expect((await post({ "sec-fetch-site": "same-origin" })).status).toBe(404);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finish a sign-in through the hub (#7894 D2, D7) — `GET /api/hub-session/callback?code=…&state=…`.
|
|
3
|
+
*
|
|
4
|
+
* The URI an operator registers on the hub (`spokes sign-in-client
|
|
5
|
+
* --redirect-uri <site>/api/hub-session/callback`). It reads and clears the
|
|
6
|
+
* flow cookie, checks the returned `state` against it in constant time, and
|
|
7
|
+
* exchanges the code, the PKCE verifier and the client secret at the hub API
|
|
8
|
+
* (the api-client adds this site's spoke key; the hub requires both). On
|
|
9
|
+
* success the credential and its person are sealed into the session cookie,
|
|
10
|
+
* whose `Max-Age` is the credential's own lifetime, and the visitor lands on
|
|
11
|
+
* the flow cookie's `next` — never on a value from this request's query.
|
|
12
|
+
*
|
|
13
|
+
* Every other outcome — no flow cookie, an expired one, a state mismatch, an
|
|
14
|
+
* `error` from the hub, a failed exchange — lands on `/account` (its gate
|
|
15
|
+
* offers to try again) after BURNING any code it received
|
|
16
|
+
* (`hubSignIn.revoke({ code })`, best-effort): a code this route cannot
|
|
17
|
+
* complete must not stay live for someone else to complete (SEC-7894-4).
|
|
18
|
+
* The log line names the reason only — never the query string, a code, a
|
|
19
|
+
* state or a credential.
|
|
20
|
+
*/
|
|
21
|
+
import { timingSafeEqual } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
24
|
+
import { hubAuthorizationCodeSchema } from "@working-theory/validation";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
type HubSessionConfig,
|
|
28
|
+
hubSessionConfig,
|
|
29
|
+
} from "../../../../lib/hub-session/config";
|
|
30
|
+
import { seal, unseal } from "../../../../lib/hub-session/seal";
|
|
31
|
+
import {
|
|
32
|
+
HUB_SESSION_FALLBACK_PATH,
|
|
33
|
+
type HubSessionPayload,
|
|
34
|
+
cookieHeader,
|
|
35
|
+
flowCookieName,
|
|
36
|
+
hubNextPath,
|
|
37
|
+
readCookie,
|
|
38
|
+
sessionCookieName,
|
|
39
|
+
} from "../../../../lib/hub-session/session";
|
|
40
|
+
import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
|
|
41
|
+
|
|
42
|
+
/** The longest session the spoke keeps — the hub's own credential lifetime (spec 7894 D5). */
|
|
43
|
+
const MAX_SESSION_SECONDS = 86_400;
|
|
44
|
+
|
|
45
|
+
/** What the sign-in route sealed into the flow cookie. */
|
|
46
|
+
interface FlowPayload {
|
|
47
|
+
v: 1;
|
|
48
|
+
state: string;
|
|
49
|
+
verifier: string;
|
|
50
|
+
next: string;
|
|
51
|
+
expiresAt: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isFlow(value: unknown): value is FlowPayload {
|
|
55
|
+
if (typeof value !== "object" || value === null) return false;
|
|
56
|
+
const v = value as Record<string, unknown>;
|
|
57
|
+
return (
|
|
58
|
+
v["v"] === 1 &&
|
|
59
|
+
typeof v["state"] === "string" &&
|
|
60
|
+
typeof v["verifier"] === "string" &&
|
|
61
|
+
typeof v["next"] === "string" &&
|
|
62
|
+
typeof v["expiresAt"] === "number"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function see(location: string, setCookies: readonly string[]): Response {
|
|
67
|
+
const headers = new Headers({
|
|
68
|
+
Location: location,
|
|
69
|
+
"Cache-Control": "no-store",
|
|
70
|
+
"Referrer-Policy": "no-referrer",
|
|
71
|
+
});
|
|
72
|
+
for (const value of setCookies) headers.append("Set-Cookie", value);
|
|
73
|
+
return new Response(null, { status: 303, headers });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sameState(a: string, b: string): boolean {
|
|
77
|
+
const x = Buffer.from(a, "utf8");
|
|
78
|
+
const y = Buffer.from(b, "utf8");
|
|
79
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Burn a code this route cannot complete. Best-effort: a failure is logged, never thrown. */
|
|
83
|
+
async function burn(config: HubSessionConfig, code: string | null): Promise<void> {
|
|
84
|
+
if (code === null || !hubAuthorizationCodeSchema.safeParse(code).success) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await hubSignIn.revoke(
|
|
89
|
+
{ clientId: config.clientId, clientSecret: config.clientSecret },
|
|
90
|
+
{ code },
|
|
91
|
+
);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error(
|
|
94
|
+
`[hub-session] code burn failed errName=${err instanceof Error ? err.name : "unknown"}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
100
|
+
export async function GET(request: Request): Promise<Response> {
|
|
101
|
+
if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
|
|
102
|
+
const config = hubSessionConfig();
|
|
103
|
+
if (config === null) return see(HUB_SESSION_FALLBACK_PATH, []);
|
|
104
|
+
|
|
105
|
+
const params = new URL(request.url).searchParams;
|
|
106
|
+
const code = params.get("code");
|
|
107
|
+
const flowName = flowCookieName(config);
|
|
108
|
+
const clearFlow = cookieHeader(flowName, "", 0, config);
|
|
109
|
+
|
|
110
|
+
const refuse = async (reason: string): Promise<Response> => {
|
|
111
|
+
await burn(config, code);
|
|
112
|
+
console.warn(`[hub-session] sign-in not completed reason=${reason}`);
|
|
113
|
+
return see(HUB_SESSION_FALLBACK_PATH, [clearFlow]);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const sealed = readCookie(request.headers.get("cookie"), flowName);
|
|
117
|
+
const flow =
|
|
118
|
+
sealed === undefined
|
|
119
|
+
? null
|
|
120
|
+
: unseal(sealed, config.sessionSecret, "flow", flowName);
|
|
121
|
+
if (!isFlow(flow)) return refuse("no_flow");
|
|
122
|
+
if (flow.expiresAt <= Date.now()) return refuse("flow_expired");
|
|
123
|
+
const state = params.get("state");
|
|
124
|
+
if (state === null || !sameState(state, flow.state)) {
|
|
125
|
+
return refuse("state_mismatch");
|
|
126
|
+
}
|
|
127
|
+
if (params.has("error")) return refuse("hub_error");
|
|
128
|
+
if (code === null) return refuse("no_code");
|
|
129
|
+
|
|
130
|
+
let exchanged: Awaited<ReturnType<typeof hubSignIn.exchange>>;
|
|
131
|
+
try {
|
|
132
|
+
exchanged = await hubSignIn.exchange(
|
|
133
|
+
{ clientId: config.clientId, clientSecret: config.clientSecret },
|
|
134
|
+
{ code, redirectUri: config.redirectUri, codeVerifier: flow.verifier },
|
|
135
|
+
);
|
|
136
|
+
} catch {
|
|
137
|
+
return refuse("exchange_failed");
|
|
138
|
+
}
|
|
139
|
+
if (!exchanged.ok) return refuse(`exchange_status_${exchanged.status}`);
|
|
140
|
+
|
|
141
|
+
const { accessToken, expiresIn, user } = exchanged.body;
|
|
142
|
+
// The cookie's lifetime is the credential's: a malformed answer from the
|
|
143
|
+
// hub must never yield a session that does not expire.
|
|
144
|
+
if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > MAX_SESSION_SECONDS) {
|
|
145
|
+
return refuse("token_response_invalid");
|
|
146
|
+
}
|
|
147
|
+
const payload: HubSessionPayload = {
|
|
148
|
+
v: 1,
|
|
149
|
+
accessToken,
|
|
150
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
151
|
+
user: {
|
|
152
|
+
name: user.name,
|
|
153
|
+
email: user.email,
|
|
154
|
+
emailVerified: user.emailVerified,
|
|
155
|
+
createdAt: user.createdAt,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const sessionName = sessionCookieName(config);
|
|
159
|
+
return see(hubNextPath(flow.next), [
|
|
160
|
+
clearFlow,
|
|
161
|
+
cookieHeader(
|
|
162
|
+
sessionName,
|
|
163
|
+
seal(payload, config.sessionSecret, "session", sessionName),
|
|
164
|
+
expiresIn,
|
|
165
|
+
config,
|
|
166
|
+
),
|
|
167
|
+
]);
|
|
168
|
+
}
|