create-ailk 0.15.0 → 0.17.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/dist/add-module.js +52 -7
- package/dist/add-route.js +8 -0
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/cli.js +82 -2
- package/dist/derive-architecture.d.ts +22 -0
- package/dist/derive-architecture.js +58 -1
- 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 +9 -0
- package/dist/lib/extract-module-bundle.js +18 -1
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.js +76 -0
- package/dist/parse-args.d.ts +5 -0
- package/dist/parse-args.js +17 -1
- package/dist/programmatic.d.ts +8 -0
- package/dist/programmatic.js +78 -9
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-manifest.d.ts +43 -0
- package/dist/site-manifest.js +98 -3
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +67 -0
- package/dist/spoke-checkout.js +562 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +23 -8
- package/dist/topology.d.ts +124 -9
- package/dist/topology.js +451 -40
- package/package.json +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +19 -4
- 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 +89 -0
- 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__/spoke-store.test.ts +20 -0
- 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/spoke-store.ts +25 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/auth.ts +126 -0
- 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/leads/__tests__/index.test.ts +7 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
- 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 +293 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +131 -2
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/web/.env.example +58 -5
- 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]/layout.tsx +28 -15
- 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 +57 -24
- package/templates/apps/web/app/openapi.json/route.ts +3 -6
- 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/lib/__tests__/account-config.test.ts +5 -2
- 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 +33 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- 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/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 +3 -1
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/topology.generated.ts +15 -5
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/database/CHANGELOG.md +26 -0
- package/templates/database/__tests__/agents.db.test.ts +25 -5
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -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 +3 -2
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +98 -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 -35
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a sign-in through the hub (#7894 D2, D7) — `GET /api/hub-session/sign-in?next=<path>`.
|
|
3
|
+
*
|
|
4
|
+
* Mints a `state` and a PKCE verifier (32 random bytes each, base64url), seals
|
|
5
|
+
* them with the sanitized `next` into the flow cookie for ten minutes, and
|
|
6
|
+
* sends the visitor to the hub's `/api/auth/hub/authorize` with the S256
|
|
7
|
+
* challenge. The redirect URI is this site's configured origin plus
|
|
8
|
+
* `/api/hub-session/callback` — never derived from the request's `Host`.
|
|
9
|
+
*
|
|
10
|
+
* - not a hub-mode spoke (`SIGN_IN_PROVIDER` is not `"hub"`): 404;
|
|
11
|
+
* - hub mode but not configured: 303 to `/account`, whose gate says so.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here logs the state, the verifier or the challenge.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
import { hubSessionConfig } from "../../../../lib/hub-session/config";
|
|
18
|
+
import { seal } from "../../../../lib/hub-session/seal";
|
|
19
|
+
import {
|
|
20
|
+
HUB_SESSION_FALLBACK_PATH,
|
|
21
|
+
cookieHeader,
|
|
22
|
+
flowCookieName,
|
|
23
|
+
hubNextPath,
|
|
24
|
+
} from "../../../../lib/hub-session/session";
|
|
25
|
+
import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
|
|
26
|
+
|
|
27
|
+
/** How long a sign-in may take on the hub before the flow cookie expires. */
|
|
28
|
+
const FLOW_TTL_SECONDS = 600;
|
|
29
|
+
|
|
30
|
+
/** The hub's browser step (spec 7894 D4). */
|
|
31
|
+
const AUTHORIZE_PATH = "/api/auth/hub/authorize";
|
|
32
|
+
|
|
33
|
+
function see(location: string, setCookie?: string): Response {
|
|
34
|
+
const headers = new Headers({
|
|
35
|
+
Location: location,
|
|
36
|
+
"Cache-Control": "no-store",
|
|
37
|
+
"Referrer-Policy": "no-referrer",
|
|
38
|
+
});
|
|
39
|
+
if (setCookie !== undefined) headers.append("Set-Cookie", setCookie);
|
|
40
|
+
return new Response(null, { status: 303, headers });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
44
|
+
export function GET(request: Request): Response {
|
|
45
|
+
if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
|
|
46
|
+
const config = hubSessionConfig();
|
|
47
|
+
if (config === null) return see(HUB_SESSION_FALLBACK_PATH);
|
|
48
|
+
|
|
49
|
+
const next = hubNextPath(new URL(request.url).searchParams.get("next"));
|
|
50
|
+
const state = randomBytes(32).toString("base64url");
|
|
51
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
52
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
53
|
+
|
|
54
|
+
const name = flowCookieName(config);
|
|
55
|
+
const flow = seal(
|
|
56
|
+
{ v: 1, state, verifier, next, expiresAt: Date.now() + FLOW_TTL_SECONDS * 1000 },
|
|
57
|
+
config.sessionSecret,
|
|
58
|
+
"flow",
|
|
59
|
+
name,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const authorize = new URL(AUTHORIZE_PATH, config.hubOrigin);
|
|
63
|
+
authorize.search = new URLSearchParams({
|
|
64
|
+
response_type: "code",
|
|
65
|
+
client_id: config.clientId,
|
|
66
|
+
redirect_uri: config.redirectUri,
|
|
67
|
+
state,
|
|
68
|
+
code_challenge: challenge,
|
|
69
|
+
code_challenge_method: "S256",
|
|
70
|
+
}).toString();
|
|
71
|
+
|
|
72
|
+
return see(
|
|
73
|
+
authorize.toString(),
|
|
74
|
+
cookieHeader(name, flow, FLOW_TTL_SECONDS, config),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign out of a spoke that signs in through its hub (#7894 D7) — `POST /api/hub-session/sign-out`.
|
|
3
|
+
*
|
|
4
|
+
* The signed-in Nav's "Sign out" posts here (`SIGN_OUT_ENDPOINT`). It revokes
|
|
5
|
+
* the credential at the hub (best-effort: a hub that cannot be reached still
|
|
6
|
+
* leaves the visitor signed out here), clears the session cookie and answers
|
|
7
|
+
* 204.
|
|
8
|
+
*
|
|
9
|
+
* Same-origin only: a `Sec-Fetch-Site` other than `same-origin`, an `Origin`
|
|
10
|
+
* that is not this site's configured origin, or a request carrying neither is
|
|
11
|
+
* refused 403 before anything is revoked, so another site cannot sign a
|
|
12
|
+
* visitor out. POST only — Next answers 405 to any other method.
|
|
13
|
+
*/
|
|
14
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
15
|
+
|
|
16
|
+
import { hubSessionConfig } from "../../../../lib/hub-session/config";
|
|
17
|
+
import {
|
|
18
|
+
cookieHeader,
|
|
19
|
+
readHubSessionPayload,
|
|
20
|
+
sessionCookieName,
|
|
21
|
+
} from "../../../../lib/hub-session/session";
|
|
22
|
+
import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
|
|
23
|
+
|
|
24
|
+
/** Whether `request` comes from a page on `siteOrigin`. */
|
|
25
|
+
function isSameOrigin(request: Request, siteOrigin: string | null): boolean {
|
|
26
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
27
|
+
const origin = request.headers.get("origin");
|
|
28
|
+
if (fetchSite === null && origin === null) return false;
|
|
29
|
+
if (fetchSite !== null && fetchSite !== "same-origin") return false;
|
|
30
|
+
if (origin !== null && origin !== siteOrigin) return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
35
|
+
export async function POST(request: Request): Promise<Response> {
|
|
36
|
+
if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
|
|
37
|
+
const config = hubSessionConfig();
|
|
38
|
+
if (!isSameOrigin(request, config?.siteOrigin ?? null)) {
|
|
39
|
+
return new Response(null, { status: 403 });
|
|
40
|
+
}
|
|
41
|
+
if (config === null) return new Response(null, { status: 204 });
|
|
42
|
+
|
|
43
|
+
const payload = readHubSessionPayload(request.headers);
|
|
44
|
+
if (payload !== null) {
|
|
45
|
+
try {
|
|
46
|
+
await hubSignIn.revoke(
|
|
47
|
+
{ clientId: config.clientId, clientSecret: config.clientSecret },
|
|
48
|
+
{ accessToken: payload.accessToken },
|
|
49
|
+
);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(
|
|
52
|
+
`[hub-session] revoke failed errName=${err instanceof Error ? err.name : "unknown"}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return new Response(null, {
|
|
57
|
+
status: 204,
|
|
58
|
+
headers: {
|
|
59
|
+
"Cache-Control": "no-store",
|
|
60
|
+
"Set-Cookie": cookieHeader(sessionCookieName(config), "", 0, config),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|