@openparachute/hub 0.7.4-rc.9 → 0.7.4
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/package.json +1 -1
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +207 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +14 -1
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -306,6 +306,58 @@ describe("renderConsent", () => {
|
|
|
306
306
|
expect(html).not.toContain("Access level");
|
|
307
307
|
expect(html).not.toContain('name="verb_select"');
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
// hub#314 — same-hub vs external trust marker. The `.badge-trust-*` class
|
|
311
|
+
// names are always present in the inlined <style> block, so assertions target
|
|
312
|
+
// the RENDERED ELEMENT form (`class="badge badge-trust-*"`) + the copy text,
|
|
313
|
+
// which only appear in the consent body when the marker actually renders.
|
|
314
|
+
test("renders the first-party trust marker for a same-hub client", () => {
|
|
315
|
+
const html = renderConsent({
|
|
316
|
+
params: PARAMS,
|
|
317
|
+
csrfToken: CSRF,
|
|
318
|
+
clientId: "c",
|
|
319
|
+
clientName: "App",
|
|
320
|
+
scopes: ["vault:read"],
|
|
321
|
+
sameHub: true,
|
|
322
|
+
});
|
|
323
|
+
expect(html).toContain('class="badge badge-trust-same-hub"');
|
|
324
|
+
expect(html).toContain(">First-party<");
|
|
325
|
+
expect(html).toContain("Registered through this hub");
|
|
326
|
+
// The external badge / copy must NOT appear for a same-hub client.
|
|
327
|
+
expect(html).not.toContain('class="badge badge-trust-external"');
|
|
328
|
+
expect(html).not.toContain("third-party app that registered itself");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("renders the external trust marker for a third-party DCR client", () => {
|
|
332
|
+
const html = renderConsent({
|
|
333
|
+
params: PARAMS,
|
|
334
|
+
csrfToken: CSRF,
|
|
335
|
+
clientId: "c",
|
|
336
|
+
clientName: "App",
|
|
337
|
+
scopes: ["vault:read"],
|
|
338
|
+
sameHub: false,
|
|
339
|
+
});
|
|
340
|
+
expect(html).toContain('class="badge badge-trust-external"');
|
|
341
|
+
expect(html).toContain(">External<");
|
|
342
|
+
expect(html).toContain("third-party app that registered itself");
|
|
343
|
+
// The first-party badge / copy must NOT appear for an external client.
|
|
344
|
+
expect(html).not.toContain('class="badge badge-trust-same-hub"');
|
|
345
|
+
expect(html).not.toContain("Registered through this hub");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("renders no trust marker when provenance is unknown (sameHub omitted)", () => {
|
|
349
|
+
const html = renderConsent({
|
|
350
|
+
params: PARAMS,
|
|
351
|
+
csrfToken: CSRF,
|
|
352
|
+
clientId: "c",
|
|
353
|
+
clientName: "App",
|
|
354
|
+
scopes: ["vault:read"],
|
|
355
|
+
// sameHub omitted → undefined → no badge
|
|
356
|
+
});
|
|
357
|
+
expect(html).not.toContain('class="trust-marker');
|
|
358
|
+
expect(html).not.toContain('class="badge badge-trust-same-hub"');
|
|
359
|
+
expect(html).not.toContain('class="badge badge-trust-external"');
|
|
360
|
+
});
|
|
309
361
|
});
|
|
310
362
|
|
|
311
363
|
describe("renderError", () => {
|
|
@@ -129,11 +129,15 @@ describe("NON_REQUESTABLE_SCOPES (#96)", () => {
|
|
|
129
129
|
expect(NON_REQUESTABLE_SCOPES.has("parachute:host:admin")).toBe(true);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
test("
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
132
|
+
test("contains the service-admin scopes hub:admin and scribe:admin (2026-06-30 over-permissioning fix)", () => {
|
|
133
|
+
// A vault MCP connector (e.g. Claude) is pointed at the hub-level AS by the
|
|
134
|
+
// vault's protected-resource metadata, so hub:admin/scribe:admin would be
|
|
135
|
+
// advertised on its consent screen + minted if approved — wildly
|
|
136
|
+
// over-privileged for a vault reader. Every legit use is operator-bearer /
|
|
137
|
+
// session (operator token, DCR self-registration, admin SPA), never
|
|
138
|
+
// /oauth/authorize — so these fail closed without breaking operator paths.
|
|
139
|
+
expect(NON_REQUESTABLE_SCOPES.has("hub:admin")).toBe(true);
|
|
140
|
+
expect(NON_REQUESTABLE_SCOPES.has("scribe:admin")).toBe(true);
|
|
137
141
|
});
|
|
138
142
|
|
|
139
143
|
test("every non-requestable scope is a known first-party scope", () => {
|
|
@@ -148,11 +152,16 @@ describe("isRequestableScope", () => {
|
|
|
148
152
|
expect(isRequestableScope("parachute:host:admin")).toBe(false);
|
|
149
153
|
});
|
|
150
154
|
|
|
151
|
-
test("
|
|
152
|
-
expect(isRequestableScope("hub:admin")).toBe(
|
|
155
|
+
test("false for service-admin scopes hub:admin and scribe:admin (operator-only, 2026-06-30)", () => {
|
|
156
|
+
expect(isRequestableScope("hub:admin")).toBe(false);
|
|
157
|
+
expect(isRequestableScope("scribe:admin")).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("true for non-admin first-party scopes", () => {
|
|
153
161
|
expect(isRequestableScope("vault:read")).toBe(true);
|
|
154
162
|
expect(isRequestableScope("vault:admin")).toBe(true);
|
|
155
163
|
expect(isRequestableScope("agent:send")).toBe(true);
|
|
164
|
+
expect(isRequestableScope("scribe:transcribe")).toBe(true);
|
|
156
165
|
});
|
|
157
166
|
|
|
158
167
|
test("true for unknown scopes (third-party module scopes pass through)", () => {
|
|
@@ -194,8 +203,10 @@ describe("isRequestableScope", () => {
|
|
|
194
203
|
expect(isNonRequestableScope("parachute:Host:Install")).toBe(true);
|
|
195
204
|
// Canonical lowercase still works unchanged.
|
|
196
205
|
expect(isNonRequestableScope("parachute:host:auth")).toBe(true);
|
|
197
|
-
//
|
|
198
|
-
expect(isNonRequestableScope("HUB:ADMIN")).toBe(
|
|
206
|
+
// Service-admin scopes are non-requestable too, case-insensitively (2026-06-30).
|
|
207
|
+
expect(isNonRequestableScope("HUB:ADMIN")).toBe(true);
|
|
208
|
+
// A genuinely requestable scope (even uppercased) stays requestable.
|
|
209
|
+
expect(isNonRequestableScope("VAULT:READ")).toBe(false);
|
|
199
210
|
});
|
|
200
211
|
});
|
|
201
212
|
|
|
@@ -5,12 +5,14 @@ import { join } from "node:path";
|
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
6
|
import {
|
|
7
7
|
SESSION_COOKIE_NAME,
|
|
8
|
+
SESSION_MAX_LIFETIME_MS,
|
|
8
9
|
buildSessionClearCookie,
|
|
9
10
|
buildSessionCookie,
|
|
10
11
|
createSession,
|
|
11
12
|
deleteSession,
|
|
12
13
|
findSession,
|
|
13
14
|
parseSessionCookie,
|
|
15
|
+
touchSession,
|
|
14
16
|
} from "../sessions.ts";
|
|
15
17
|
import { createUser } from "../users.ts";
|
|
16
18
|
|
|
@@ -67,6 +69,84 @@ describe("createSession + findSession", () => {
|
|
|
67
69
|
});
|
|
68
70
|
});
|
|
69
71
|
|
|
72
|
+
describe("touchSession (sliding renewal)", () => {
|
|
73
|
+
const HOUR = 3600 * 1000;
|
|
74
|
+
const DAY = 24 * HOUR;
|
|
75
|
+
|
|
76
|
+
test("slides expires_at forward to now + TTL", async () => {
|
|
77
|
+
const { db, userId, cleanup } = await makeDb();
|
|
78
|
+
try {
|
|
79
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
80
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
81
|
+
// Original expiry: t0 + 24h.
|
|
82
|
+
expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + DAY);
|
|
83
|
+
// Touch 1h later → expiry becomes (t0 + 1h) + 24h.
|
|
84
|
+
const t1 = new Date(t0.getTime() + HOUR);
|
|
85
|
+
touchSession(db, s.id, () => t1);
|
|
86
|
+
const found = findSession(db, s.id, () => t1);
|
|
87
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + DAY);
|
|
88
|
+
} finally {
|
|
89
|
+
cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a touched session outlives the ORIGINAL 24h expiry", async () => {
|
|
94
|
+
const { db, userId, cleanup } = await makeDb();
|
|
95
|
+
try {
|
|
96
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
97
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
98
|
+
// Activity at +12h slides expiry to +36h.
|
|
99
|
+
touchSession(db, s.id, () => new Date(t0.getTime() + 12 * HOUR));
|
|
100
|
+
// At +30h — PAST the original +24h — the session is still alive.
|
|
101
|
+
const at30h = new Date(t0.getTime() + 30 * HOUR);
|
|
102
|
+
expect(findSession(db, s.id, () => at30h)?.id).toBe(s.id);
|
|
103
|
+
} finally {
|
|
104
|
+
cleanup();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("an UNtouched session still expires at the original 24h", async () => {
|
|
109
|
+
const { db, userId, cleanup } = await makeDb();
|
|
110
|
+
try {
|
|
111
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
112
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
113
|
+
// No touch — at +25h it's gone (today's absolute-TTL behavior preserved
|
|
114
|
+
// for idle / closed tabs that stop re-minting).
|
|
115
|
+
const at25h = new Date(t0.getTime() + 25 * HOUR);
|
|
116
|
+
expect(findSession(db, s.id, () => at25h)).toBeNull();
|
|
117
|
+
} finally {
|
|
118
|
+
cleanup();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("caps at created_at + SESSION_MAX_LIFETIME_MS (sliding can't run forever)", async () => {
|
|
123
|
+
const { db, userId, cleanup } = await makeDb();
|
|
124
|
+
try {
|
|
125
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
126
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
127
|
+
const ceiling = t0.getTime() + SESSION_MAX_LIFETIME_MS;
|
|
128
|
+
// A touch near the ceiling would slide to now + 24h, but the cap pins it.
|
|
129
|
+
const nearCeiling = new Date(ceiling - HOUR); // raw slide would be ceiling + 23h
|
|
130
|
+
touchSession(db, s.id, () => nearCeiling);
|
|
131
|
+
const found = findSession(db, s.id, () => nearCeiling);
|
|
132
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(ceiling);
|
|
133
|
+
// Past the ceiling the session is dead even though it was just "active".
|
|
134
|
+
expect(findSession(db, s.id, () => new Date(ceiling + 1000))).toBeNull();
|
|
135
|
+
} finally {
|
|
136
|
+
cleanup();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("no-op on an unknown session id (does not throw)", async () => {
|
|
141
|
+
const { db, cleanup } = await makeDb();
|
|
142
|
+
try {
|
|
143
|
+
expect(() => touchSession(db, "no-such-session")).not.toThrow();
|
|
144
|
+
} finally {
|
|
145
|
+
cleanup();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
70
150
|
describe("deleteSession", () => {
|
|
71
151
|
test("removes the session row", async () => {
|
|
72
152
|
const { db, userId, cleanup } = await makeDb();
|
|
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
33
33
|
import { hubFetch } from "../hub-server.ts";
|
|
34
|
+
import { setRootRedirect, setSetting } from "../hub-settings.ts";
|
|
34
35
|
import { writeManifest } from "../services-manifest.ts";
|
|
35
36
|
import { createUser } from "../users.ts";
|
|
36
37
|
|
|
@@ -101,9 +102,7 @@ describe("setup gate (no admin yet)", () => {
|
|
|
101
102
|
test("/login POST still 503s setup_required when no admin exists (hub#644)", async () => {
|
|
102
103
|
const db = openHubDb(hubDbPath(h.dir));
|
|
103
104
|
try {
|
|
104
|
-
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
105
|
-
req("/login", { method: "POST" }),
|
|
106
|
-
);
|
|
105
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/login", { method: "POST" }));
|
|
107
106
|
expect(res.status).toBe(503);
|
|
108
107
|
const body = (await res.json()) as Record<string, unknown>;
|
|
109
108
|
expect(body.error).toBe("setup_required");
|
|
@@ -368,3 +367,112 @@ describe("setup gate (admin exists)", () => {
|
|
|
368
367
|
}
|
|
369
368
|
});
|
|
370
369
|
});
|
|
370
|
+
|
|
371
|
+
describe("configurable bare-`/` redirect target", () => {
|
|
372
|
+
let h: Harness;
|
|
373
|
+
beforeEach(() => {
|
|
374
|
+
h = makeHarness();
|
|
375
|
+
});
|
|
376
|
+
afterEach(() => h.cleanup());
|
|
377
|
+
|
|
378
|
+
/** A set-up hub (admin + vault) so the bare-`/` redirect is reached. */
|
|
379
|
+
function setUpHub(db: ReturnType<typeof openHubDb>): void {
|
|
380
|
+
writeManifest(
|
|
381
|
+
{
|
|
382
|
+
services: [
|
|
383
|
+
{
|
|
384
|
+
name: "parachute-vault",
|
|
385
|
+
version: "0.1.0",
|
|
386
|
+
port: 1940,
|
|
387
|
+
paths: ["/vault/default"],
|
|
388
|
+
health: "/health",
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
join(h.dir, "services.json"),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function handler(db: ReturnType<typeof openHubDb>) {
|
|
397
|
+
return hubFetch(h.dir, { getDb: () => db, manifestPath: join(h.dir, "services.json") });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test("a configured root_redirect retargets the bare-`/` 302", async () => {
|
|
401
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
402
|
+
try {
|
|
403
|
+
await createUser(db, "owner", "pw");
|
|
404
|
+
setUpHub(db);
|
|
405
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
406
|
+
const res = await handler(db)(req("/"));
|
|
407
|
+
expect(res.status).toBe(302);
|
|
408
|
+
expect(res.headers.get("location")).toBe("/surface/reading-room");
|
|
409
|
+
} finally {
|
|
410
|
+
db.close();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("an unsafe stored root_redirect falls back to /admin (never an open redirect)", async () => {
|
|
415
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
416
|
+
try {
|
|
417
|
+
await createUser(db, "owner", "pw");
|
|
418
|
+
setUpHub(db);
|
|
419
|
+
// A hand-edited sqlite row that bypassed write-side validation.
|
|
420
|
+
setSetting(db, "root_redirect", "//evil.com");
|
|
421
|
+
const res = await handler(db)(req("/"));
|
|
422
|
+
expect(res.status).toBe(302);
|
|
423
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
424
|
+
} finally {
|
|
425
|
+
db.close();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("PARACHUTE_HUB_ROOT_REDIRECT env retargets the bare-`/` 302 (no DB row)", async () => {
|
|
430
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
431
|
+
const prev = process.env.PARACHUTE_HUB_ROOT_REDIRECT;
|
|
432
|
+
process.env.PARACHUTE_HUB_ROOT_REDIRECT = "/surface/from-env";
|
|
433
|
+
try {
|
|
434
|
+
await createUser(db, "owner", "pw");
|
|
435
|
+
setUpHub(db);
|
|
436
|
+
const res = await handler(db)(req("/"));
|
|
437
|
+
expect(res.status).toBe(302);
|
|
438
|
+
expect(res.headers.get("location")).toBe("/surface/from-env");
|
|
439
|
+
} finally {
|
|
440
|
+
// Restore process.env to its pre-test state. `delete` (not assign-undefined,
|
|
441
|
+
// which would coerce to the string "undefined") removes a key we added.
|
|
442
|
+
if (prev === undefined) {
|
|
443
|
+
// biome-ignore lint/performance/noDelete: env-key cleanup, not a hot path
|
|
444
|
+
delete process.env.PARACHUTE_HUB_ROOT_REDIRECT;
|
|
445
|
+
} else {
|
|
446
|
+
process.env.PARACHUTE_HUB_ROOT_REDIRECT = prev;
|
|
447
|
+
}
|
|
448
|
+
db.close();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("wizard funnel WINS: a configured root_redirect does NOT bypass setup on a fresh hub", async () => {
|
|
453
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
454
|
+
try {
|
|
455
|
+
// No admin yet → not-set-up hub. Even with a surface configured, the
|
|
456
|
+
// bare-`/` must funnel to the wizard, not a surface that can't work yet.
|
|
457
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
458
|
+
const res = await handler(db)(req("/"));
|
|
459
|
+
expect(res.status).toBe(302);
|
|
460
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
461
|
+
} finally {
|
|
462
|
+
db.close();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("default is unchanged: bare-`/` → /admin when nothing is configured", async () => {
|
|
467
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
468
|
+
try {
|
|
469
|
+
await createUser(db, "owner", "pw");
|
|
470
|
+
setUpHub(db);
|
|
471
|
+
const res = await handler(db)(req("/"));
|
|
472
|
+
expect(res.status).toBe(302);
|
|
473
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
474
|
+
} finally {
|
|
475
|
+
db.close();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -83,6 +83,7 @@ const SUCCESS_BODY = {
|
|
|
83
83
|
grants_dropped: 2,
|
|
84
84
|
user_vaults_removed: 4,
|
|
85
85
|
invites_invalidated: 1,
|
|
86
|
+
vault_cap_removed: true,
|
|
86
87
|
connections_torn_down: 1,
|
|
87
88
|
orphaned_channels: [],
|
|
88
89
|
vault_removed: true,
|
|
@@ -156,6 +157,7 @@ describe("vaultRemove — 200 success", () => {
|
|
|
156
157
|
expect(text).toContain("3");
|
|
157
158
|
expect(text).toContain("user_vaults removed:");
|
|
158
159
|
expect(text).toContain("4");
|
|
160
|
+
expect(text).toContain("storage cap removed:");
|
|
159
161
|
expect(text).toContain("vault removed:");
|
|
160
162
|
});
|
|
161
163
|
|
|
@@ -194,19 +196,35 @@ describe("vaultRemove — 200 success", () => {
|
|
|
194
196
|
});
|
|
195
197
|
});
|
|
196
198
|
|
|
197
|
-
describe("vaultRemove —
|
|
198
|
-
test("
|
|
199
|
+
describe("vaultRemove — last vault (#678: cascade-then-delete, no 409)", () => {
|
|
200
|
+
test("the last vault deletes via the cascade (200) and NEVER spawns parachute-vault directly", async () => {
|
|
199
201
|
await withSpawnSpy(async (spawned) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
// The endpoint no longer refuses the last vault — it returns 200 with the
|
|
203
|
+
// cascade summary, identical to any other delete. The CLI just renders it.
|
|
204
|
+
const lastVaultBody = {
|
|
205
|
+
ok: true,
|
|
206
|
+
name: "scratch",
|
|
207
|
+
cascade: {
|
|
208
|
+
tokens_revoked: 2,
|
|
209
|
+
grants_rewritten: 0,
|
|
210
|
+
grants_dropped: 1,
|
|
211
|
+
user_vaults_removed: 1,
|
|
212
|
+
invites_invalidated: 0,
|
|
213
|
+
vault_cap_removed: true,
|
|
214
|
+
connections_torn_down: 0,
|
|
215
|
+
orphaned_channels: [],
|
|
216
|
+
vault_removed: true,
|
|
217
|
+
module_restarted: true,
|
|
208
218
|
},
|
|
209
|
-
|
|
219
|
+
warnings: [
|
|
220
|
+
{
|
|
221
|
+
step: "last_vault",
|
|
222
|
+
detail:
|
|
223
|
+
"the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: lastVaultBody }]);
|
|
210
228
|
const sinks = makeSinks();
|
|
211
229
|
const code = await vaultRemove(["scratch"], {
|
|
212
230
|
resolveBearer: async () => BEARER,
|
|
@@ -214,17 +232,20 @@ describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
|
|
|
214
232
|
log: sinks.log,
|
|
215
233
|
logError: sinks.logError,
|
|
216
234
|
});
|
|
217
|
-
//
|
|
218
|
-
expect(code).
|
|
219
|
-
// Exactly ONE fetch (the DELETE) —
|
|
235
|
+
// 200 → success exit; the cascade did its work.
|
|
236
|
+
expect(code).toBe(0);
|
|
237
|
+
// Exactly ONE fetch (the DELETE) — the cascade runs server-side over loopback.
|
|
220
238
|
expect(calls).toHaveLength(1);
|
|
221
239
|
expect(calls[0]?.method).toBe("DELETE");
|
|
222
|
-
// The load-bearing invariant:
|
|
240
|
+
// The load-bearing invariant still holds: the CLI never spawns
|
|
241
|
+
// `parachute-vault` itself — destruction goes through the hub endpoint.
|
|
223
242
|
expect(spawned.count).toBe(0);
|
|
224
|
-
//
|
|
225
|
-
const
|
|
226
|
-
expect(
|
|
227
|
-
expect(
|
|
243
|
+
// The cascade summary renders, including the last_vault heads-up warning.
|
|
244
|
+
const text = sinks.text();
|
|
245
|
+
expect(text).toContain("tokens revoked:");
|
|
246
|
+
expect(text).toContain("vault removed:");
|
|
247
|
+
expect(text).toContain("last_vault");
|
|
248
|
+
expect(text).toContain("auto_create: false");
|
|
228
249
|
});
|
|
229
250
|
});
|
|
230
251
|
});
|
package/src/account-setup.ts
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
* scope-guard.
|
|
63
63
|
*/
|
|
64
64
|
import type { Database } from "bun:sqlite";
|
|
65
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
65
66
|
import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
|
|
66
67
|
import { type RunResult, provisionVault } from "./admin-vaults.ts";
|
|
67
68
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
@@ -528,6 +529,7 @@ export async function handleAccountSetupPost(
|
|
|
528
529
|
|
|
529
530
|
// (6) Sign the invitee in + land them on /account/.
|
|
530
531
|
const session = createSession(deps.db, { userId });
|
|
532
|
+
recordLoginUnlock(deps.db, session.id);
|
|
531
533
|
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
532
534
|
secure: isHttpsRequest(req),
|
|
533
535
|
});
|
|
@@ -131,6 +131,16 @@ export interface AgentGrantsDeps {
|
|
|
131
131
|
* (`<hubOrigin>/oauth/agent-grant/callback`).
|
|
132
132
|
*/
|
|
133
133
|
hubOrigin: string;
|
|
134
|
+
/**
|
|
135
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
136
|
+
* per-request issuer), built via `buildHubBoundOrigins`. The module's
|
|
137
|
+
* host-admin bearer `iss` is validated against THIS set rather than the
|
|
138
|
+
* single `hubOrigin`, so the agent module's credential minted under a
|
|
139
|
+
* still-valid prior origin keeps working across an origin switch (hub#516
|
|
140
|
+
* parity). Minted tokens still carry `hubOrigin`. Absent → falls back to
|
|
141
|
+
* `[hubOrigin]` (the prior strict per-request behavior).
|
|
142
|
+
*/
|
|
143
|
+
knownIssuers?: readonly string[];
|
|
134
144
|
/** Absolute path to `agent-grants.json` in the hub state dir. */
|
|
135
145
|
storePath: string;
|
|
136
146
|
/** Absolute path to `agent-oauth-flows.json` (the in-flight OAuth consents, 4b-2). */
|
|
@@ -249,7 +259,12 @@ async function requireModuleAuth(
|
|
|
249
259
|
deps: AgentGrantsDeps,
|
|
250
260
|
): Promise<AdminAuthContext | Response> {
|
|
251
261
|
try {
|
|
252
|
-
return await requireScope(
|
|
262
|
+
return await requireScope(
|
|
263
|
+
deps.db,
|
|
264
|
+
req,
|
|
265
|
+
HOST_ADMIN_SCOPE,
|
|
266
|
+
deps.knownIssuers ?? [deps.hubOrigin],
|
|
267
|
+
);
|
|
253
268
|
} catch (err) {
|
|
254
269
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
255
270
|
}
|
package/src/admin-auth.ts
CHANGED
|
@@ -59,15 +59,24 @@ export function extractBearerToken(req: Request): string {
|
|
|
59
59
|
* and check it carries `requiredScope`. Returns surfaced claims on success;
|
|
60
60
|
* throws `AdminAuthError` (401 or 403) otherwise.
|
|
61
61
|
*
|
|
62
|
-
* `expectedIssuer`
|
|
63
|
-
* tokens we sign.
|
|
64
|
-
*
|
|
62
|
+
* `expectedIssuer` is the hub's own origin(s) — the same value(s) baked into
|
|
63
|
+
* tokens we sign. Pass a single string for a single-origin hub, or the SET of
|
|
64
|
+
* origins the hub legitimately answers on (`buildHubBoundOrigins`: loopback ∪
|
|
65
|
+
* expose-state ∪ platform ∪ per-request issuer) so a credential minted under
|
|
66
|
+
* a still-valid prior origin keeps validating across an origin switch — the
|
|
67
|
+
* same multi-origin posture the OAuth path and `validateHostAdminToken`
|
|
68
|
+
* already use. Defense in depth: even though we can only verify our own keys,
|
|
69
|
+
* the `iss`-∈-set reject keeps cross-issuer confusion impossible. SECURITY:
|
|
70
|
+
* the set is ONLY an additive `iss` membership relaxation — `validateAccessToken`
|
|
71
|
+
* verifies the signature against the hub's own key FIRST, so only tokens this
|
|
72
|
+
* hub minted ever reach the `iss` check; never pass a raw request Host, only a
|
|
73
|
+
* `buildHubBoundOrigins`-derived set.
|
|
65
74
|
*/
|
|
66
75
|
export async function requireScope(
|
|
67
76
|
db: Database,
|
|
68
77
|
req: Request,
|
|
69
78
|
requiredScope: string,
|
|
70
|
-
expectedIssuer: string,
|
|
79
|
+
expectedIssuer: string | readonly string[],
|
|
71
80
|
): Promise<AdminAuthContext> {
|
|
72
81
|
const token = extractBearerToken(req);
|
|
73
82
|
|
package/src/admin-clients.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* without round-tripping through the `/oauth/authorize` flow (whose
|
|
5
5
|
* `POST /oauth/authorize/approve` requires a `return_to` authorize URL).
|
|
6
6
|
*
|
|
7
|
-
* GET
|
|
8
|
-
* POST
|
|
7
|
+
* GET /api/oauth/clients/<client_id> client details
|
|
8
|
+
* POST /api/oauth/clients/<client_id>/approve flip status to approved
|
|
9
|
+
* DELETE /oauth/clients/<client_id> deregister (RFC 7592) — note
|
|
10
|
+
* the TOP-LEVEL prefix, see
|
|
11
|
+
* handleDeleteClient
|
|
9
12
|
*
|
|
10
13
|
* Both gated by `parachute:host:admin` Bearer (same shape as /api/grants,
|
|
11
14
|
* /api/auth/tokens, etc.). The SPA mints one via the session cookie at
|
|
@@ -54,13 +57,22 @@ import {
|
|
|
54
57
|
requireScope,
|
|
55
58
|
} from "./admin-auth.ts";
|
|
56
59
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
57
|
-
import { approveClient, getClient } from "./clients.ts";
|
|
60
|
+
import { approveClient, deleteClient, getClient } from "./clients.ts";
|
|
58
61
|
import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
|
|
59
62
|
|
|
60
63
|
export interface AdminClientsDeps {
|
|
61
64
|
db: Database;
|
|
62
65
|
/** Hub origin — passed through to JWT validation as the expected `iss`. */
|
|
63
66
|
issuer: string;
|
|
67
|
+
/**
|
|
68
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
69
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
70
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
71
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
72
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
73
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
74
|
+
*/
|
|
75
|
+
knownIssuers?: readonly string[];
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
export interface AdminClientView {
|
|
@@ -90,7 +102,7 @@ export async function handleGetClient(
|
|
|
90
102
|
return jsonError(405, "method_not_allowed", "use GET");
|
|
91
103
|
}
|
|
92
104
|
try {
|
|
93
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
105
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
94
106
|
} catch (err) {
|
|
95
107
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
96
108
|
}
|
|
@@ -126,7 +138,7 @@ export async function handleApproveClient(
|
|
|
126
138
|
}
|
|
127
139
|
let ctx: AdminAuthContext;
|
|
128
140
|
try {
|
|
129
|
-
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
141
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
130
142
|
} catch (err) {
|
|
131
143
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
132
144
|
}
|
|
@@ -177,6 +189,55 @@ export async function handleApproveClient(
|
|
|
177
189
|
});
|
|
178
190
|
}
|
|
179
191
|
|
|
192
|
+
/**
|
|
193
|
+
* RFC 7592 Dynamic Client Registration *deletion* (deregistration).
|
|
194
|
+
*
|
|
195
|
+
* DELETE /oauth/clients/<client_id> remove the client + its cascade
|
|
196
|
+
*
|
|
197
|
+
* Mounted at the TOP-LEVEL `/oauth/clients/` prefix (NOT under `/api/...`)
|
|
198
|
+
* because that's the path parachute-surface's remove-flow actually calls
|
|
199
|
+
* (`packages/surface-host/src/dcr.ts` → `DELETE <hub>/oauth/clients/<id>`),
|
|
200
|
+
* carrying the operator token as a Bearer. Before this route existed the
|
|
201
|
+
* hub 404'd every such DELETE, so every Notes/Claude reconnect orphaned a
|
|
202
|
+
* `clients` row in the operator's DB (closes hub#640, 4/5 boxes — the GC
|
|
203
|
+
* reaper for legacy orphans is a separate follow-up).
|
|
204
|
+
*
|
|
205
|
+
* Auth mirrors `handleGetClient`: `parachute:host:admin` Bearer via
|
|
206
|
+
* `requireScope`. Returns 204 (no content) on a successful delete, 404 when
|
|
207
|
+
* the client isn't registered — the same shape the surface already tolerates
|
|
208
|
+
* (`hubDeleteStatus: "ok"` on 200/204, `"not_found"` on a JSON 404).
|
|
209
|
+
*
|
|
210
|
+
* Audit: emits a `client deleted: ...` line in the same `key=value` shape as
|
|
211
|
+
* the `client approved: ...` line, so cross-machine "who removed this client"
|
|
212
|
+
* is greppable in hub.log.
|
|
213
|
+
*/
|
|
214
|
+
export async function handleDeleteClient(
|
|
215
|
+
req: Request,
|
|
216
|
+
clientId: string,
|
|
217
|
+
deps: AdminClientsDeps,
|
|
218
|
+
): Promise<Response> {
|
|
219
|
+
if (req.method !== "DELETE") {
|
|
220
|
+
return jsonError(405, "method_not_allowed", "use DELETE");
|
|
221
|
+
}
|
|
222
|
+
let ctx: AdminAuthContext;
|
|
223
|
+
try {
|
|
224
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
227
|
+
}
|
|
228
|
+
// Capture the name BEFORE deleting so the audit line can carry it.
|
|
229
|
+
const before = getClient(deps.db, clientId);
|
|
230
|
+
const removed = deleteClient(deps.db, clientId);
|
|
231
|
+
if (!removed) {
|
|
232
|
+
return jsonError(404, "not_found", `no client registered with id ${clientId}`);
|
|
233
|
+
}
|
|
234
|
+
console.log(
|
|
235
|
+
`client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=${ctx.sub}`,
|
|
236
|
+
);
|
|
237
|
+
// 204 No Content — RFC 7592 §2.3 prescribes 204 for a successful delete.
|
|
238
|
+
return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
239
|
+
}
|
|
240
|
+
|
|
180
241
|
interface ApproveClientResponse {
|
|
181
242
|
client_id: string;
|
|
182
243
|
status: "approved";
|
package/src/admin-grants.ts
CHANGED
|
@@ -38,6 +38,15 @@ export interface AdminGrantsDeps {
|
|
|
38
38
|
db: Database;
|
|
39
39
|
/** Hub origin — passed through to JWT validation as the expected `iss`. */
|
|
40
40
|
issuer: string;
|
|
41
|
+
/**
|
|
42
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
43
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
44
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
45
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
46
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
47
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
48
|
+
*/
|
|
49
|
+
knownIssuers?: readonly string[];
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
export interface AdminGrantListing {
|
|
@@ -55,7 +64,7 @@ export async function handleListGrants(req: Request, deps: AdminGrantsDeps): Pro
|
|
|
55
64
|
}
|
|
56
65
|
let ctx: AdminAuthContext;
|
|
57
66
|
try {
|
|
58
|
-
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
67
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
59
68
|
} catch (err) {
|
|
60
69
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
61
70
|
}
|
|
@@ -111,7 +120,7 @@ export async function handleRevokeGrant(
|
|
|
111
120
|
}
|
|
112
121
|
let ctx: AdminAuthContext;
|
|
113
122
|
try {
|
|
114
|
-
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
123
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
115
124
|
} catch (err) {
|
|
116
125
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
117
126
|
}
|