@openparachute/hub 0.7.6 → 0.7.7-rc.12

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.
Files changed (78) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +12 -0
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-server.test.ts +37 -0
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +279 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/scope-explanations.test.ts +22 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +372 -132
  36. package/src/__tests__/sessions.test.ts +75 -28
  37. package/src/__tests__/setup-wizard.test.ts +7 -10
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/account-api.ts +677 -0
  42. package/src/account-session.ts +132 -0
  43. package/src/account-token.ts +200 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-invites.ts +19 -0
  50. package/src/api-mint-token.ts +2 -1
  51. package/src/api-modules-ops.ts +2 -1
  52. package/src/api-modules.ts +10 -8
  53. package/src/api-revoke-token.ts +2 -1
  54. package/src/api-settings-hub-origin.ts +2 -1
  55. package/src/api-settings-root-redirect.ts +2 -1
  56. package/src/api-tokens.ts +2 -1
  57. package/src/audience-gate.ts +2 -1
  58. package/src/chrome-strip.ts +16 -4
  59. package/src/commands/install.ts +86 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/help.ts +6 -6
  63. package/src/hub-server.ts +247 -52
  64. package/src/hub-settings.ts +25 -1
  65. package/src/hub.ts +64 -31
  66. package/src/invites.ts +42 -0
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/notes-serve.ts +73 -31
  69. package/src/oauth-handlers.ts +1 -11
  70. package/src/operator-token.ts +0 -1
  71. package/src/origin-check.ts +2 -2
  72. package/src/scope-explanations.ts +35 -5
  73. package/src/service-spec.ts +128 -74
  74. package/src/services-manifest.ts +112 -52
  75. package/src/sessions.ts +66 -30
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. package/src/admin-agent-token.ts +0 -147
@@ -0,0 +1,252 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { ACCOUNT_ERROR_CODES, checkAccountSessionResponse } from "@openparachute/door-contract";
7
+ import { handleAccountSession } from "../account-session.ts";
8
+ import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken, parseCsrfCookie } from "../csrf.ts";
9
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
10
+ import {
11
+ SESSION_COOKIE_NAME,
12
+ SESSION_SLIDE_THRESHOLD_MS,
13
+ SESSION_TTL_MS,
14
+ buildSessionCookie,
15
+ createSession,
16
+ findSession,
17
+ } from "../sessions.ts";
18
+ import { createUser } from "../users.ts";
19
+
20
+ /**
21
+ * Tests for `GET /account/session` — the same-origin boot oracle (hub-parity
22
+ * P1, the twin of cloud's `account-session.ts`). Covers:
23
+ * - both branches' body shape (signed-out / signed-in).
24
+ * - CSRF minted when absent, reused (no re-Set-Cookie) when present — on
25
+ * BOTH branches (the G2 anonymous-CSRF invariant).
26
+ * - a deleted-user session row falls back to signed-out.
27
+ * - the bounded slide: an old-but-live session rolls + re-issues the
28
+ * cookie; a fresh session does neither.
29
+ * - 405 on non-GET, `cache-control: no-store`, no CORS headers.
30
+ * - drift: `checkAccountSessionResponse` reports zero issues against the
31
+ * live handler on both branches.
32
+ */
33
+ const ISSUER = "https://hub.test";
34
+
35
+ interface Harness {
36
+ db: Database;
37
+ cleanup: () => void;
38
+ }
39
+
40
+ function makeHarness(): Harness {
41
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-session-"));
42
+ const db = openHubDb(hubDbPath(dir));
43
+ return {
44
+ db,
45
+ cleanup: () => {
46
+ db.close();
47
+ rmSync(dir, { recursive: true, force: true });
48
+ },
49
+ };
50
+ }
51
+
52
+ let harness: Harness;
53
+ beforeEach(() => {
54
+ harness = makeHarness();
55
+ });
56
+ afterEach(() => {
57
+ harness.cleanup();
58
+ });
59
+
60
+ function getReq(cookie?: string, method = "GET"): Request {
61
+ return new Request(`${ISSUER}/account/session`, {
62
+ method,
63
+ ...(cookie ? { headers: { cookie } } : {}),
64
+ });
65
+ }
66
+
67
+ function sessionCookiePair(sid: string): string {
68
+ return buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000));
69
+ }
70
+
71
+ describe("handleAccountSession — signed-out branch", () => {
72
+ test("no cookie at all → {signed_in:false, csrf} + CSRF Set-Cookie minted", async () => {
73
+ const res = handleAccountSession(getReq(), { db: harness.db });
74
+ expect(res.status).toBe(200);
75
+ const body = (await res.json()) as { signed_in: boolean; csrf: string };
76
+ expect(body.signed_in).toBe(false);
77
+ expect(typeof body.csrf).toBe("string");
78
+ expect(body.csrf.length).toBeGreaterThan(0);
79
+
80
+ const setCookie = res.headers.getSetCookie();
81
+ const csrfSet = setCookie.find((c) => c.includes(CSRF_COOKIE_NAME));
82
+ expect(csrfSet).toBeDefined();
83
+ expect(parseCsrfCookie(csrfSet ?? null)).toBe(body.csrf);
84
+ });
85
+
86
+ test("reuses an existing CSRF cookie without re-minting (no Set-Cookie)", async () => {
87
+ const existing = generateCsrfToken();
88
+ const cookie = buildCsrfCookie(existing).split(";")[0] ?? "";
89
+ const res = handleAccountSession(getReq(cookie), { db: harness.db });
90
+ expect(res.status).toBe(200);
91
+ const body = (await res.json()) as { signed_in: boolean; csrf: string };
92
+ expect(body.signed_in).toBe(false);
93
+ expect(body.csrf).toBe(existing);
94
+ expect(res.headers.get("set-cookie")).toBeNull();
95
+ });
96
+
97
+ test("a session cookie naming a deleted user → signed-out", async () => {
98
+ // The on-disk FK normally guards against a session outliving its user, so
99
+ // forge the orphaned row directly with `PRAGMA foreign_keys=OFF` (the
100
+ // `api-account.test.ts` precedent) — simulating a delete-user-via-SQL-shell
101
+ // race, not something reachable through the app's own APIs.
102
+ const sessionId = "test-orphaned-session-id-base64url";
103
+ harness.db.exec("PRAGMA foreign_keys = OFF");
104
+ try {
105
+ harness.db
106
+ .prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
107
+ .run(
108
+ sessionId,
109
+ "nonexistent-user-uuid",
110
+ new Date(Date.now() + SESSION_TTL_MS).toISOString(),
111
+ new Date().toISOString(),
112
+ );
113
+ } finally {
114
+ harness.db.exec("PRAGMA foreign_keys = ON");
115
+ }
116
+ const cookie = sessionCookiePair(sessionId);
117
+ const res = handleAccountSession(getReq(cookie), { db: harness.db });
118
+ expect(res.status).toBe(200);
119
+ const body = (await res.json()) as { signed_in: boolean };
120
+ expect(body.signed_in).toBe(false);
121
+ });
122
+
123
+ test("drift: checkAccountSessionResponse reports zero issues (signed-out)", async () => {
124
+ const res = handleAccountSession(getReq(), { db: harness.db });
125
+ const body = (await res.json()) as Record<string, unknown>;
126
+ expect(checkAccountSessionResponse(body, { signedIn: false })).toEqual([]);
127
+ });
128
+ });
129
+
130
+ describe("handleAccountSession — signed-in branch", () => {
131
+ test("username always present; no email when the user row has none", async () => {
132
+ const user = await createUser(harness.db, "operator", "pw", { passwordChanged: true });
133
+ const session = createSession(harness.db, { userId: user.id });
134
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
135
+ expect(res.status).toBe(200);
136
+ const body = (await res.json()) as Record<string, unknown>;
137
+ expect(body.signed_in).toBe(true);
138
+ expect(body.username).toBe("operator");
139
+ expect(body.email).toBeUndefined();
140
+ expect(body.account_created_at).toBe(user.createdAt);
141
+ expect(body.password_change_required).toBeUndefined();
142
+ });
143
+
144
+ test("email present when the user row has one", async () => {
145
+ const user = await createUser(harness.db, "withmail", "pw", {
146
+ passwordChanged: true,
147
+ email: "friend@example.com",
148
+ });
149
+ const session = createSession(harness.db, { userId: user.id });
150
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
151
+ const body = (await res.json()) as Record<string, unknown>;
152
+ expect(body.email).toBe("friend@example.com");
153
+ expect(body.username).toBe("withmail");
154
+ });
155
+
156
+ test("password_change_required:true for a not-yet-rotated (passwordChanged:false) user", async () => {
157
+ // createUser defaults passwordChanged to false — the admin-created-user
158
+ // posture (force-redirect on first sign-in).
159
+ const user = await createUser(harness.db, "temp", "pw");
160
+ const session = createSession(harness.db, { userId: user.id });
161
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
162
+ const body = (await res.json()) as Record<string, unknown>;
163
+ expect(body.signed_in).toBe(true);
164
+ expect(body.password_change_required).toBe(true);
165
+ });
166
+
167
+ test("mints CSRF when absent even on the signed-in branch", async () => {
168
+ const user = await createUser(harness.db, "operator2", "pw", { passwordChanged: true });
169
+ const session = createSession(harness.db, { userId: user.id });
170
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
171
+ const body = (await res.json()) as { csrf: string };
172
+ expect(typeof body.csrf).toBe("string");
173
+ expect(body.csrf.length).toBeGreaterThan(0);
174
+ const setCookie = res.headers.getSetCookie();
175
+ expect(setCookie.some((c) => c.includes(CSRF_COOKIE_NAME))).toBe(true);
176
+ });
177
+
178
+ test("drift: checkAccountSessionResponse reports zero issues (signed-in)", async () => {
179
+ const user = await createUser(harness.db, "driftcheck", "pw", { passwordChanged: true });
180
+ const session = createSession(harness.db, { userId: user.id });
181
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
182
+ const body = (await res.json()) as Record<string, unknown>;
183
+ expect(checkAccountSessionResponse(body, { signedIn: true })).toEqual([]);
184
+ });
185
+ });
186
+
187
+ describe("handleAccountSession — bounded slide", () => {
188
+ test("an old-but-live session (within the slide threshold of expiry) rolls + re-issues the cookie", async () => {
189
+ const user = await createUser(harness.db, "slider", "pw", { passwordChanged: true });
190
+ const t0 = new Date();
191
+ // Created far enough in the past that remaining life < TTL - threshold.
192
+ const createdAt = new Date(
193
+ t0.getTime() - (SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS + 60_000),
194
+ );
195
+ const session = createSession(harness.db, { userId: user.id, now: () => createdAt });
196
+ const originalExpiry = new Date(session.expiresAt).getTime();
197
+
198
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
199
+ expect(res.status).toBe(200);
200
+ const setCookie = res.headers.getSetCookie();
201
+ const sessionSet = setCookie.find((c) => c.includes(SESSION_COOKIE_NAME));
202
+ expect(sessionSet).toBeDefined();
203
+ expect(sessionSet).toContain("HttpOnly");
204
+ expect(sessionSet).toContain("SameSite=Lax");
205
+
206
+ const found = findSession(harness.db, session.id);
207
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
208
+ });
209
+
210
+ test("a fresh session does NOT slide — no session Set-Cookie, no DB write", async () => {
211
+ const user = await createUser(harness.db, "fresh", "pw", { passwordChanged: true });
212
+ const session = createSession(harness.db, { userId: user.id });
213
+ const originalExpiry = session.expiresAt;
214
+
215
+ const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
216
+ expect(res.status).toBe(200);
217
+ const setCookie = res.headers.getSetCookie();
218
+ expect(setCookie.some((c) => c.includes(SESSION_COOKIE_NAME))).toBe(false);
219
+
220
+ const found = findSession(harness.db, session.id);
221
+ expect(found?.expiresAt).toBe(originalExpiry);
222
+ });
223
+ });
224
+
225
+ describe("handleAccountSession — method + headers", () => {
226
+ test("405 on POST", async () => {
227
+ const res = handleAccountSession(getReq(undefined, "POST"), { db: harness.db });
228
+ expect(res.status).toBe(405);
229
+ expect(res.headers.get("allow")).toBe("GET");
230
+ // H1.3 — pin the CURRENT shape: resource-level failures on /account/*
231
+ // emit {error, message} (door-contract account-contract.ts:244-246). This
232
+ // is account-session.ts's own `methodNotAllowed`, distinct from
233
+ // account-token.ts's {error, error_description} 405 (see H1.1/H1.3 PR
234
+ // notes — a decisions-needed inconsistency, not fixed here).
235
+ const body = (await res.json()) as { error: string; message: string };
236
+ expect(body).toEqual({ error: "method_not_allowed", message: "use GET" });
237
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
238
+ });
239
+
240
+ test("cache-control: no-store on the 200 path", async () => {
241
+ const res = handleAccountSession(getReq(), { db: harness.db });
242
+ expect(res.headers.get("cache-control")).toBe("no-store");
243
+ });
244
+
245
+ test("no access-control-allow-origin (same-origin only, no CORS)", async () => {
246
+ const req = new Request(`${ISSUER}/account/session`, {
247
+ headers: { origin: "https://evil.example" },
248
+ });
249
+ const res = handleAccountSession(req, { db: harness.db });
250
+ expect(res.headers.get("access-control-allow-origin")).toBeNull();
251
+ });
252
+ });
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Tests for `POST /account/token` — the cookie→account-bearer mint (Parachute
3
+ * App campaign, Phase 2 H1). Covers:
4
+ * - 405 on a non-POST method.
5
+ * - 401 when no admin session cookie is present (unauthenticated → refused).
6
+ * - 401 when the cookie names a deleted session.
7
+ * - 403 csrf_failed when the double-submit CSRF token is missing / mismatched.
8
+ * - 403 not_admin when a signed-in non-first-admin (friend) hits the endpoint.
9
+ * - 200 mint carrying the superset {account:self:admin, parachute:host:admin,
10
+ * parachute:host:auth}, aud="account", validating against the hub's keys.
11
+ * - scope-guard admin ⊇ read: account:self:read is inherited by the minted
12
+ * account:self:admin (the RS-side check the `/account/*` validator runs).
13
+ * - the superset actually unlocks a host-admin-gated endpoint (requireScope
14
+ * accepts parachute:host:admin even though aud="account" — SCOPE-b).
15
+ * - sliding session renewal on the success path.
16
+ */
17
+ import type { Database } from "bun:sqlite";
18
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { ACCOUNT_ERROR_CODES, checkAccountTokenMintResponse } from "@openparachute/door-contract";
23
+ // The `/account/*` validator (H2 / cloud) hand-rolls its scope check via
24
+ // scope-guard's hasScope — hub never imports scope-guard at runtime (the
25
+ // issuer/validator boundary), but the test asserts the RS-side inheritance
26
+ // the account grammar must satisfy. Same relative import as account-setup.test.
27
+ import { hasScope } from "../../packages/scope-guard/src/scope.ts";
28
+ import {
29
+ ACCOUNT_TOKEN_AUDIENCE,
30
+ ACCOUNT_TOKEN_TTL_SECONDS,
31
+ handleAccountToken,
32
+ } from "../account-token.ts";
33
+ import { requireScope } from "../admin-auth.ts";
34
+ import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
35
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
36
+ import { validateAccessToken } from "../jwt-sign.ts";
37
+ import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "../scope-explanations.ts";
38
+ import {
39
+ SESSION_TTL_MS,
40
+ buildSessionCookie,
41
+ createSession,
42
+ deleteSession,
43
+ findSession,
44
+ } from "../sessions.ts";
45
+ import { rotateSigningKey } from "../signing-keys.ts";
46
+ import { createUser } from "../users.ts";
47
+
48
+ const ISSUER = "https://hub.test";
49
+
50
+ interface Harness {
51
+ db: Database;
52
+ cleanup: () => void;
53
+ }
54
+
55
+ function makeHarness(): Harness {
56
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-token-"));
57
+ const db = openHubDb(hubDbPath(dir));
58
+ return {
59
+ db,
60
+ cleanup: () => {
61
+ db.close();
62
+ rmSync(dir, { recursive: true, force: true });
63
+ },
64
+ };
65
+ }
66
+
67
+ let harness: Harness;
68
+ beforeEach(() => {
69
+ harness = makeHarness();
70
+ });
71
+ afterEach(() => {
72
+ harness.cleanup();
73
+ });
74
+
75
+ /**
76
+ * Build a first-admin session + a matching CSRF token pair. Returns the cookie
77
+ * header (session + csrf) and the CSRF token to place in the JSON body — the
78
+ * double-submit both halves must agree on.
79
+ */
80
+ async function withAdminSession(): Promise<{
81
+ cookie: string;
82
+ csrf: string;
83
+ userId: string;
84
+ }> {
85
+ const user = await createUser(harness.db, "operator", "operator-passphrase");
86
+ const session = createSession(harness.db, { userId: user.id });
87
+ const csrf = generateCsrfToken();
88
+ const cookie = [
89
+ buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
90
+ buildCsrfCookie(csrf).split(";")[0],
91
+ ].join("; ");
92
+ return { cookie, csrf, userId: user.id };
93
+ }
94
+
95
+ /** Seed an admin + a second non-admin friend; return the friend's cookie/csrf. */
96
+ async function withFriendSession(): Promise<{ cookie: string; csrf: string }> {
97
+ await createUser(harness.db, "admin", "admin-passphrase");
98
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", { allowMulti: true });
99
+ const session = createSession(harness.db, { userId: friend.id });
100
+ const csrf = generateCsrfToken();
101
+ const cookie = [
102
+ buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
103
+ buildCsrfCookie(csrf).split(";")[0],
104
+ ].join("; ");
105
+ return { cookie, csrf };
106
+ }
107
+
108
+ function postReq(cookie: string, body: Record<string, unknown>): Request {
109
+ return new Request(`${ISSUER}/account/token`, {
110
+ method: "POST",
111
+ headers: { cookie, "content-type": "application/json" },
112
+ body: JSON.stringify(body),
113
+ });
114
+ }
115
+
116
+ describe("handleAccountToken", () => {
117
+ test("405 on GET", async () => {
118
+ const { cookie } = await withAdminSession();
119
+ const req = new Request(`${ISSUER}/account/token`, { headers: { cookie } });
120
+ const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
121
+ expect(res.status).toBe(405);
122
+ // H1.3 — pin the CURRENT shape: account-token.ts emits {error,
123
+ // error_description} uniformly (its own local `jsonError`), including for
124
+ // this 405 — DIFFERENT from account-session.ts's 405, which emits {error,
125
+ // message} (see that file's test). A decisions-needed inconsistency
126
+ // across /account/*, not fixed here.
127
+ const body = (await res.json()) as { error: string; error_description: string };
128
+ expect(body).toEqual({ error: "method_not_allowed", error_description: "use POST" });
129
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
130
+ });
131
+
132
+ test("401 when no session cookie is present (unauthenticated → refused)", async () => {
133
+ // No session AND no CSRF: the session gate fires first, so the refusal is a
134
+ // clean 401 rather than a CSRF 403.
135
+ const req = postReq("", { [CSRF_FIELD_NAME]: "anything" });
136
+ const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
137
+ expect(res.status).toBe(401);
138
+ const body = (await res.json()) as { error: string; error_description: string };
139
+ expect(body.error).toBe("unauthenticated");
140
+ // H1.3 — pin the CURRENT shape: auth-gate failures on /account/* emit
141
+ // {error, error_description} (door-contract account-contract.ts:244-246).
142
+ expect(typeof body.error_description).toBe("string");
143
+ expect(body.error_description.length).toBeGreaterThan(0);
144
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
145
+ });
146
+
147
+ test("401 when the cookie names a deleted session", async () => {
148
+ const { cookie, csrf } = await withAdminSession();
149
+ const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
150
+ deleteSession(harness.db, sid);
151
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
152
+ db: harness.db,
153
+ issuer: ISSUER,
154
+ });
155
+ expect(res.status).toBe(401);
156
+ });
157
+
158
+ test("403 csrf_failed when the CSRF token is absent from the body", async () => {
159
+ const { cookie } = await withAdminSession();
160
+ rotateSigningKey(harness.db);
161
+ const res = await handleAccountToken(postReq(cookie, {}), { db: harness.db, issuer: ISSUER });
162
+ expect(res.status).toBe(403);
163
+ const body = (await res.json()) as { error: string; error_description: string };
164
+ expect(body.error).toBe("csrf_failed");
165
+ // H1.3 — pin the {error, error_description} shape + ACCOUNT_ERROR_CODES membership.
166
+ expect(typeof body.error_description).toBe("string");
167
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
168
+ });
169
+
170
+ test("403 csrf_failed when the body token doesn't match the cookie token", async () => {
171
+ const { cookie } = await withAdminSession();
172
+ rotateSigningKey(harness.db);
173
+ // A well-formed but wrong token (never the cookie's value).
174
+ const res = await handleAccountToken(
175
+ postReq(cookie, { [CSRF_FIELD_NAME]: generateCsrfToken() }),
176
+ { db: harness.db, issuer: ISSUER },
177
+ );
178
+ expect(res.status).toBe(403);
179
+ expect(((await res.json()) as { error: string }).error).toBe("csrf_failed");
180
+ });
181
+
182
+ test("403 not_admin when a signed-in friend (non-first-admin) mints", async () => {
183
+ // Privesc closure: a valid friend session + valid CSRF must still be
184
+ // refused — the account superset carries host:admin + host:auth, which a
185
+ // non-admin must never hold. The gate keys off first-admin (account ≡ box).
186
+ const { cookie, csrf } = await withFriendSession();
187
+ rotateSigningKey(harness.db);
188
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
189
+ db: harness.db,
190
+ issuer: ISSUER,
191
+ });
192
+ expect(res.status).toBe(403);
193
+ const body = (await res.json()) as { error: string; error_description: string };
194
+ expect(body.error).toBe("not_admin");
195
+ expect(body.error_description).toContain("/account/");
196
+ // H1.3 — DECISIONS-NEEDED (cataloged, not fixed here): "not_admin" is a
197
+ // real emitted /account/* error code but is NOT a member of door-contract's
198
+ // ACCOUNT_ERROR_CODES union — a gap in the shared vocabulary. See the PR
199
+ // body's decisions-needed section rather than asserting membership here.
200
+ });
201
+
202
+ test("200 mints the account superset with aud=account, validating against the hub keys", async () => {
203
+ const { cookie, csrf, userId } = await withAdminSession();
204
+ rotateSigningKey(harness.db);
205
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
206
+ db: harness.db,
207
+ issuer: ISSUER,
208
+ });
209
+ expect(res.status).toBe(200);
210
+ expect(res.headers.get("cache-control")).toBe("no-store");
211
+
212
+ const body = (await res.json()) as {
213
+ token: string;
214
+ expires_at: string;
215
+ scopes: string[];
216
+ aud: string;
217
+ };
218
+ // The superset (SCOPE-b): account scope + the host superset.
219
+ expect(body.scopes).toEqual([
220
+ "account:self:admin",
221
+ "parachute:host:admin",
222
+ "parachute:host:auth",
223
+ ]);
224
+ expect(body.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
225
+ // H1.2 — door-contract conformance against the live `POST /account/token`
226
+ // success body (V1.4/C1.4 twin coverage, hub half).
227
+ expect(checkAccountTokenMintResponse(body)).toEqual([]);
228
+
229
+ // TTL is roughly the 10-min window.
230
+ const skew = new Date(body.expires_at).getTime() - Date.now();
231
+ expect(skew).toBeGreaterThan((ACCOUNT_TOKEN_TTL_SECONDS - 30) * 1000);
232
+ expect(skew).toBeLessThan((ACCOUNT_TOKEN_TTL_SECONDS + 30) * 1000);
233
+
234
+ // JWT verifies against the hub's own signing key + issuer, and carries the
235
+ // explicit account audience (SCOPE-a).
236
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
237
+ expect(validated.payload.sub).toBe(userId);
238
+ expect(validated.payload.iss).toBe(ISSUER);
239
+ expect(validated.payload.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
240
+ const scopes = ((validated.payload as { scope?: string }).scope ?? "").split(/\s+/);
241
+ expect(scopes).toContain("account:self:admin");
242
+ expect(scopes).toContain("parachute:host:admin");
243
+ expect(scopes).toContain("parachute:host:auth");
244
+ });
245
+
246
+ test("scope-guard: account:self:read is inherited by the minted account:self:admin", async () => {
247
+ // The `/account/*` validator (H2 / cloud) hand-rolls admin ⊇ read via
248
+ // scope-guard. The minted token carries account:self:admin; a read-gated
249
+ // account endpoint must accept it, an admin-gated one too, and neither a
250
+ // different account id nor an unrelated resource must match.
251
+ const { cookie, csrf } = await withAdminSession();
252
+ rotateSigningKey(harness.db);
253
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
254
+ db: harness.db,
255
+ issuer: ISSUER,
256
+ });
257
+ const { scopes } = (await res.json()) as { scopes: string[] };
258
+
259
+ expect(hasScope(scopes, ACCOUNT_SELF_ADMIN_SCOPE)).toBe(true);
260
+ expect(hasScope(scopes, ACCOUNT_SELF_READ_SCOPE)).toBe(true); // admin ⊇ read
261
+ // A different account id must NOT be satisfied (name-pinned inheritance).
262
+ expect(hasScope(scopes, "account:other:read")).toBe(false);
263
+ // account:self:admin must never satisfy a vault scope.
264
+ expect(hasScope(scopes, "vault:self:read")).toBe(false);
265
+ });
266
+
267
+ test("the minted superset unlocks a host-admin-gated endpoint (SCOPE-b, aud not pinned)", async () => {
268
+ // requireScope validates signature + iss + the scope claim but does NOT pin
269
+ // aud, so the aud="account" token is accepted on the host-scoped surfaces
270
+ // (POST /vaults, /api/auth/*) it wraps — no existing gate needs rewiring.
271
+ const { cookie, csrf } = await withAdminSession();
272
+ rotateSigningKey(harness.db);
273
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
274
+ db: harness.db,
275
+ issuer: ISSUER,
276
+ });
277
+ const { token } = (await res.json()) as { token: string };
278
+
279
+ const bearerReq = new Request(`${ISSUER}/vaults`, {
280
+ method: "POST",
281
+ headers: { authorization: `Bearer ${token}` },
282
+ });
283
+ const ctx = await requireScope(harness.db, bearerReq, "parachute:host:admin", ISSUER);
284
+ expect(ctx.scopes).toContain("account:self:admin");
285
+ expect(ctx.audience).toBe(ACCOUNT_TOKEN_AUDIENCE);
286
+ });
287
+
288
+ test("200 slides the session expiry forward and re-issues the cookie", async () => {
289
+ const user = await createUser(harness.db, "operator", "operator-passphrase");
290
+ const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
291
+ const session = createSession(harness.db, { userId: user.id, now: () => twelveHoursAgo });
292
+ const originalExpiry = new Date(session.expiresAt).getTime();
293
+ const csrf = generateCsrfToken();
294
+ const cookie = [
295
+ buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
296
+ buildCsrfCookie(csrf).split(";")[0],
297
+ ].join("; ");
298
+ rotateSigningKey(harness.db);
299
+
300
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
301
+ db: harness.db,
302
+ issuer: ISSUER,
303
+ });
304
+ expect(res.status).toBe(200);
305
+
306
+ const setCookie = res.headers.get("set-cookie") ?? "";
307
+ expect(setCookie).toContain("parachute_hub_session=");
308
+ expect(setCookie).toContain("HttpOnly");
309
+ expect(setCookie).toContain("Secure"); // ISSUER is https
310
+ expect(setCookie).toContain("SameSite=Lax");
311
+ expect(setCookie.toLowerCase()).not.toContain("domain=");
312
+
313
+ const found = findSession(harness.db, session.id);
314
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
315
+ });
316
+ });
@@ -556,6 +556,26 @@ describe("credential connection — renewal (proof of possession)", () => {
556
556
  expect(rec.provisioned.mintedJtis).toEqual([out.credential.jti!]);
557
557
  });
558
558
 
559
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
560
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
561
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
562
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
563
+ const cred = await provision(deps, calls);
564
+
565
+ const res = await handleConnections(
566
+ new Request(`http://127.0.0.1/admin/connections/${cred.connection_id}/renew`, {
567
+ method: "POST",
568
+ headers: { authorization: `bearer ${cred.token}` },
569
+ }),
570
+ `/${cred.connection_id}/renew`,
571
+ deps,
572
+ );
573
+ expect(res.status).toBe(200);
574
+ const out = (await res.json()) as { ok: boolean; credential: DeliveredCredential };
575
+ expect(out.ok).toBe(true);
576
+ expect(out.credential.op).toBe("renewed");
577
+ });
578
+
559
579
  test("renewed credential can renew again (the chain extends)", async () => {
560
580
  const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
561
581
  const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
@@ -941,6 +961,27 @@ describe("credential connection — claim/reconcile (surface#113)", () => {
941
961
  ]);
942
962
  });
943
963
 
964
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
965
+ test("claim: mixed-case bearer scheme (BeArEr) authenticates identically to canonical Bearer", async () => {
966
+ const { fetchImpl } = mockFetch({});
967
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
968
+ const direct = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["boulder"] });
969
+
970
+ const claim = await handleConnections(
971
+ new Request(`http://127.0.0.1/admin/connections/${CLAIM_ID}/claim`, {
972
+ method: "POST",
973
+ headers: { "content-type": "application/json", authorization: `BeArEr ${direct.token}` },
974
+ body: JSON.stringify(SURFACE_CLAIM),
975
+ }),
976
+ `/${CLAIM_ID}/claim`,
977
+ deps,
978
+ );
979
+ expect(claim.status).toBe(202);
980
+ const claimBody = (await claim.json()) as { ok: boolean; status: string };
981
+ expect(claimBody.ok).toBe(true);
982
+ expect(claimBody.status).toBe("pending");
983
+ });
984
+
944
985
  test("the live docs shape: an UNTAGGED write credential is claimable verbatim (create's write-requires-tags guards NEW grants; the operator's approve sanctions the existing shape)", async () => {
945
986
  const { fetchImpl } = mockFetch({});
946
987
  const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
@@ -603,4 +603,29 @@ describe("handleAdminLogoutPost (#113)", () => {
603
603
  expect(res.headers.get("location")).toBe("/login");
604
604
  expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
605
605
  });
606
+
607
+ // App-wire pin (hub-parity P1, §8-M4): the app's `logout()` posts form-
608
+ // encoded, not JSON (`parachute-app/src/lib/account/client.ts:246-258`:
609
+ // `content-type: application/x-www-form-urlencoded`, body
610
+ // `new URLSearchParams({ __csrf: csrf }).toString()`, credentials included).
611
+ // No hub code changed for this — the existing formData()-based handler
612
+ // already accepts exactly this shape; this test freezes the byte-shape so a
613
+ // future refactor of the handler can't silently drop form-body support out
614
+ // from under the app.
615
+ test("app-wire pin — form-encoded body matching client.ts's exact logout() shape", async () => {
616
+ const cookie = await cookieForUser(harness.db, "appuser", "pw");
617
+ const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
618
+ const req = new Request("http://hub.test/logout", {
619
+ method: "POST",
620
+ headers: {
621
+ cookie,
622
+ "content-type": "application/x-www-form-urlencoded",
623
+ },
624
+ body: new URLSearchParams({ [CSRF_FIELD_NAME]: TEST_CSRF }).toString(),
625
+ });
626
+ const res = await handleAdminLogoutPost(harness.db, req);
627
+ expect(res.status).toBe(302);
628
+ expect(res.headers.get("location")).toBe("/login");
629
+ expect(findSession(harness.db, sid)).toBeNull();
630
+ });
606
631
  });