@openparachute/hub 0.7.4-rc.8 → 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 +276 -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/__tests__/well-known.test.ts +37 -2
- 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 +25 -5
- 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/src/well-known.ts +10 -1
- 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
|
@@ -119,7 +119,9 @@ describe("authorizationServerMetadata", () => {
|
|
|
119
119
|
expect(scopesSupported).toContain("vault:read");
|
|
120
120
|
expect(scopesSupported).toContain("vault:admin");
|
|
121
121
|
expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
|
|
122
|
-
|
|
122
|
+
// hub:admin + scribe:admin are operator-only (non-requestable) — never advertised (2026-06-30)
|
|
123
|
+
expect(scopesSupported).not.toContain("hub:admin");
|
|
124
|
+
expect(scopesSupported).not.toContain("scribe:admin");
|
|
123
125
|
// agent isn't in the fixture manifest → its scopes aren't advertised
|
|
124
126
|
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
125
127
|
expect(scopesSupported).not.toContain("agent:send");
|
|
@@ -169,9 +171,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
169
171
|
// First-party still advertised — no regression
|
|
170
172
|
expect(scopesSupported).toContain("vault:read");
|
|
171
173
|
expect(scopesSupported).toContain("vault:admin");
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
+
// NON_REQUESTABLE filter applies even when the scope is declared:
|
|
175
|
+
// host-* AND the service-admin scopes (hub:admin/scribe:admin) are filtered.
|
|
174
176
|
expect(scopesSupported).not.toContain("parachute:host:admin");
|
|
177
|
+
expect(scopesSupported).not.toContain("hub:admin");
|
|
175
178
|
});
|
|
176
179
|
|
|
177
180
|
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
@@ -209,7 +212,8 @@ describe("authorizationServerMetadata", () => {
|
|
|
209
212
|
// core scopes survive
|
|
210
213
|
expect(scopes).toContain("vault:read");
|
|
211
214
|
expect(scopes).toContain("vault:admin");
|
|
212
|
-
|
|
215
|
+
// hub:admin is operator-only (non-requestable) — never advertised
|
|
216
|
+
expect(scopes).not.toContain("hub:admin");
|
|
213
217
|
// uninstalled optional-module scopes are dropped
|
|
214
218
|
expect(scopes).not.toContain("scribe:transcribe");
|
|
215
219
|
expect(scopes).not.toContain("scribe:admin");
|
|
@@ -241,6 +245,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
241
245
|
});
|
|
242
246
|
const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
243
247
|
expect(scopes2).toContain("scribe:transcribe");
|
|
248
|
+
// scribe:admin stays dropped EVEN WITH scribe installed — proving it's the
|
|
249
|
+
// requestability gate (non-requestable, 2026-06-30) doing the work here, not
|
|
250
|
+
// the optional-module-not-installed gate that drops scribe:transcribe above.
|
|
251
|
+
expect(scopes2).not.toContain("scribe:admin");
|
|
244
252
|
expect(scopes2).not.toContain("agent:send"); // agent still not installed
|
|
245
253
|
});
|
|
246
254
|
});
|
|
@@ -353,6 +361,88 @@ describe("handleAuthorizeGet", () => {
|
|
|
353
361
|
}
|
|
354
362
|
});
|
|
355
363
|
|
|
364
|
+
// hub#314 — same-hub vs external trust marker reaches the rendered consent
|
|
365
|
+
// screen via `client.sameHub`. An unnamed `vault:read` request from a
|
|
366
|
+
// same-hub client falls through to consent (the auto-approve gate requires
|
|
367
|
+
// `!hasUnnamedVault`), so we can assert the marker on the GET render.
|
|
368
|
+
test("renders the EXTERNAL trust marker for a third-party DCR client", async () => {
|
|
369
|
+
const { db, cleanup } = await makeDb();
|
|
370
|
+
try {
|
|
371
|
+
const user = await createUser(db, "owner", "pw");
|
|
372
|
+
const session = createSession(db, { userId: user.id });
|
|
373
|
+
// registerClient defaults sameHub:false → external (third-party DCR).
|
|
374
|
+
const reg = registerClient(db, {
|
|
375
|
+
redirectUris: ["https://app.example/cb"],
|
|
376
|
+
clientName: "ThirdPartyApp",
|
|
377
|
+
});
|
|
378
|
+
const { challenge } = makePkce();
|
|
379
|
+
const req = new Request(
|
|
380
|
+
authorizeUrl({
|
|
381
|
+
client_id: reg.client.clientId,
|
|
382
|
+
redirect_uri: "https://app.example/cb",
|
|
383
|
+
response_type: "code",
|
|
384
|
+
code_challenge: challenge,
|
|
385
|
+
code_challenge_method: "S256",
|
|
386
|
+
scope: "vault:read",
|
|
387
|
+
}),
|
|
388
|
+
{
|
|
389
|
+
headers: {
|
|
390
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
const html = await res.text();
|
|
397
|
+
// Element form — the `.badge-trust-*` CSS class names are always in the
|
|
398
|
+
// inlined <style>; the rendered ELEMENT only appears when the marker fires.
|
|
399
|
+
expect(html).toContain('class="badge badge-trust-external"');
|
|
400
|
+
expect(html).toContain("third-party app that registered itself");
|
|
401
|
+
expect(html).not.toContain('class="badge badge-trust-same-hub"');
|
|
402
|
+
} finally {
|
|
403
|
+
cleanup();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("renders the FIRST-PARTY trust marker for a same-hub client", async () => {
|
|
408
|
+
const { db, cleanup } = await makeDb();
|
|
409
|
+
try {
|
|
410
|
+
const user = await createUser(db, "owner", "pw");
|
|
411
|
+
const session = createSession(db, { userId: user.id });
|
|
412
|
+
const reg = registerClient(db, {
|
|
413
|
+
redirectUris: ["https://app.example/cb"],
|
|
414
|
+
clientName: "FirstPartyApp",
|
|
415
|
+
sameHub: true,
|
|
416
|
+
});
|
|
417
|
+
const { challenge } = makePkce();
|
|
418
|
+
const req = new Request(
|
|
419
|
+
authorizeUrl({
|
|
420
|
+
client_id: reg.client.clientId,
|
|
421
|
+
redirect_uri: "https://app.example/cb",
|
|
422
|
+
response_type: "code",
|
|
423
|
+
code_challenge: challenge,
|
|
424
|
+
code_challenge_method: "S256",
|
|
425
|
+
// Unnamed vault verb → bypasses the same-hub auto-approve gate
|
|
426
|
+
// (`!hasUnnamedVault`) and falls through to the consent render.
|
|
427
|
+
scope: "vault:read",
|
|
428
|
+
}),
|
|
429
|
+
{
|
|
430
|
+
headers: {
|
|
431
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
436
|
+
expect(res.status).toBe(200);
|
|
437
|
+
const html = await res.text();
|
|
438
|
+
expect(html).toContain('class="badge badge-trust-same-hub"');
|
|
439
|
+
expect(html).toContain("Registered through this hub");
|
|
440
|
+
expect(html).not.toContain('class="badge badge-trust-external"');
|
|
441
|
+
} finally {
|
|
442
|
+
cleanup();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
356
446
|
test("rejects unknown client_id with 400", async () => {
|
|
357
447
|
const { db, cleanup } = await makeDb();
|
|
358
448
|
try {
|
|
@@ -2914,6 +3004,75 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
2914
3004
|
notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
|
|
2915
3005
|
});
|
|
2916
3006
|
});
|
|
3007
|
+
|
|
3008
|
+
// closes #478 — an empty-paths vault row ("installed but no servable
|
|
3009
|
+
// instance"; vault's self-register emits `paths: []` at zero vaults) must
|
|
3010
|
+
// NOT synthesize a phantom `vault` / `vault:default` entry pointing at
|
|
3011
|
+
// root in the /oauth/token services catalog. Pre-fix the `["/"]` fallback
|
|
3012
|
+
// resolved `vaultInstanceNameFor(name, "/")` → "default" and advertised
|
|
3013
|
+
// `${ISSUER}/` as the vault. Mirrors the skip in well-known.ts /
|
|
3014
|
+
// admin-vaults.ts / vault-names.ts.
|
|
3015
|
+
test("empty-paths vault row produces NO catalog entry — no phantom default (#478)", () => {
|
|
3016
|
+
const emptyPathsManifest: ServicesManifest = {
|
|
3017
|
+
services: [
|
|
3018
|
+
{
|
|
3019
|
+
name: "parachute-vault",
|
|
3020
|
+
port: 1940,
|
|
3021
|
+
paths: [],
|
|
3022
|
+
health: "/vault/default/health",
|
|
3023
|
+
version: "0.7.0",
|
|
3024
|
+
},
|
|
3025
|
+
],
|
|
3026
|
+
};
|
|
3027
|
+
// Broad scope: would have leaked `vault` + `vault:default` at `/`.
|
|
3028
|
+
expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:read"])).toEqual({});
|
|
3029
|
+
// Per-vault-narrowed scope for the phantom name: also nothing.
|
|
3030
|
+
expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:default:read"])).toEqual({});
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
test("positive control: a vault row WITH a path is still cataloged (#478)", () => {
|
|
3034
|
+
const realManifest: ServicesManifest = {
|
|
3035
|
+
services: [
|
|
3036
|
+
{
|
|
3037
|
+
name: "parachute-vault",
|
|
3038
|
+
port: 1940,
|
|
3039
|
+
paths: ["/vault/default"],
|
|
3040
|
+
health: "/vault/default/health",
|
|
3041
|
+
version: "0.7.0",
|
|
3042
|
+
},
|
|
3043
|
+
],
|
|
3044
|
+
};
|
|
3045
|
+
expect(buildServicesCatalog(realManifest, ISSUER, ["vault:read"])).toEqual({
|
|
3046
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.7.0" },
|
|
3047
|
+
});
|
|
3048
|
+
});
|
|
3049
|
+
|
|
3050
|
+
test("empty-paths vault row alongside a real vault: only the real one is cataloged (#478)", () => {
|
|
3051
|
+
// A transitional manifest could carry both a path-less bare row and a
|
|
3052
|
+
// real instance row. The empty-paths row must contribute nothing; the
|
|
3053
|
+
// real vault is unaffected.
|
|
3054
|
+
const mixedManifest: ServicesManifest = {
|
|
3055
|
+
services: [
|
|
3056
|
+
{
|
|
3057
|
+
name: "parachute-vault",
|
|
3058
|
+
port: 1940,
|
|
3059
|
+
paths: [],
|
|
3060
|
+
health: "/vault/default/health",
|
|
3061
|
+
version: "0.7.0",
|
|
3062
|
+
},
|
|
3063
|
+
{
|
|
3064
|
+
name: "parachute-vault-work",
|
|
3065
|
+
port: 1941,
|
|
3066
|
+
paths: ["/vault/work"],
|
|
3067
|
+
health: "/vault/work/health",
|
|
3068
|
+
version: "0.7.0",
|
|
3069
|
+
},
|
|
3070
|
+
],
|
|
3071
|
+
};
|
|
3072
|
+
expect(buildServicesCatalog(mixedManifest, ISSUER, ["vault:read"])).toEqual({
|
|
3073
|
+
vault: { url: `${ISSUER}/vault/work`, version: "0.7.0" },
|
|
3074
|
+
});
|
|
3075
|
+
});
|
|
2917
3076
|
});
|
|
2918
3077
|
});
|
|
2919
3078
|
|
|
@@ -7215,12 +7374,12 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7215
7374
|
}
|
|
7216
7375
|
});
|
|
7217
7376
|
|
|
7218
|
-
test("authorize: same_hub=true + admin
|
|
7219
|
-
// hub:admin is requestable via
|
|
7220
|
-
//
|
|
7221
|
-
//
|
|
7222
|
-
//
|
|
7223
|
-
// hub-
|
|
7377
|
+
test("authorize: same_hub=true + hub:admin → invalid_scope (operator-only, even for same-hub) (2026-06-30)", async () => {
|
|
7378
|
+
// hub:admin is now non-requestable via /oauth/authorize (a vault MCP
|
|
7379
|
+
// connector pointed at the hub-level AS would otherwise be offered
|
|
7380
|
+
// hub-wide admin). Even a trusted same-hub client with an owner session
|
|
7381
|
+
// is rejected with invalid_scope — fails closed before consent. The
|
|
7382
|
+
// legit hub-admin paths are operator-bearer/session, not authorize-flow.
|
|
7224
7383
|
const { db, cleanup } = await makeDb();
|
|
7225
7384
|
try {
|
|
7226
7385
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -7239,24 +7398,24 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7239
7398
|
scope: "hub:admin",
|
|
7240
7399
|
code_challenge: challenge,
|
|
7241
7400
|
code_challenge_method: "S256",
|
|
7401
|
+
state: "abc",
|
|
7242
7402
|
}),
|
|
7243
7403
|
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7244
7404
|
);
|
|
7245
7405
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
expect(
|
|
7249
|
-
|
|
7250
|
-
expect(html).toContain("hub:admin");
|
|
7406
|
+
expect(res.status).toBe(302);
|
|
7407
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7408
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7409
|
+
expect(loc.searchParams.get("error_description")).toContain("hub:admin");
|
|
7251
7410
|
} finally {
|
|
7252
7411
|
cleanup();
|
|
7253
7412
|
}
|
|
7254
7413
|
});
|
|
7255
7414
|
|
|
7256
|
-
test("authorize: same_hub=true + mixed
|
|
7257
|
-
//
|
|
7258
|
-
//
|
|
7259
|
-
//
|
|
7415
|
+
test("authorize: same_hub=true + mixed vault + hub:admin → invalid_scope (a non-requestable scope rejects the whole request) (2026-06-30)", async () => {
|
|
7416
|
+
// A request mixing a requestable scope with hub:admin must NOT slip the
|
|
7417
|
+
// non-requestable scope through on the strength of the others — the whole
|
|
7418
|
+
// request is rejected with invalid_scope (same as parachute:host:admin).
|
|
7260
7419
|
const { db, cleanup } = await makeDb();
|
|
7261
7420
|
try {
|
|
7262
7421
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -7275,12 +7434,52 @@ describe("DCR same-hub auto-trust (hub#312)", () => {
|
|
|
7275
7434
|
scope: "vault:default:read hub:admin",
|
|
7276
7435
|
code_challenge: challenge,
|
|
7277
7436
|
code_challenge_method: "S256",
|
|
7437
|
+
state: "abc",
|
|
7278
7438
|
}),
|
|
7279
7439
|
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7280
7440
|
);
|
|
7281
7441
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7282
|
-
expect(res.status).toBe(
|
|
7283
|
-
|
|
7442
|
+
expect(res.status).toBe(302);
|
|
7443
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7444
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7445
|
+
expect(loc.searchParams.get("error_description")).toContain("hub:admin");
|
|
7446
|
+
} finally {
|
|
7447
|
+
cleanup();
|
|
7448
|
+
}
|
|
7449
|
+
});
|
|
7450
|
+
|
|
7451
|
+
test("authorize: explicit scribe:admin → invalid_scope (service-admin, operator-only) (2026-06-30)", async () => {
|
|
7452
|
+
// Symmetry with hub:admin: the other service-admin scope is non-requestable
|
|
7453
|
+
// too. Scribe's admin UI gets scribe:admin via the cookie-gated
|
|
7454
|
+
// /admin/module-token/scribe path, never /oauth/authorize — so rejecting it
|
|
7455
|
+
// here breaks no first-party path.
|
|
7456
|
+
const { db, cleanup } = await makeDb();
|
|
7457
|
+
try {
|
|
7458
|
+
const user = await createUser(db, "owner", "pw");
|
|
7459
|
+
const session = createSession(db, { userId: user.id });
|
|
7460
|
+
const reg = registerClient(db, {
|
|
7461
|
+
redirectUris: ["https://app.example/cb"],
|
|
7462
|
+
status: "approved",
|
|
7463
|
+
sameHub: true,
|
|
7464
|
+
});
|
|
7465
|
+
const { challenge } = makePkce();
|
|
7466
|
+
const req = new Request(
|
|
7467
|
+
authorizeUrl({
|
|
7468
|
+
client_id: reg.client.clientId,
|
|
7469
|
+
redirect_uri: "https://app.example/cb",
|
|
7470
|
+
response_type: "code",
|
|
7471
|
+
scope: "scribe:admin",
|
|
7472
|
+
code_challenge: challenge,
|
|
7473
|
+
code_challenge_method: "S256",
|
|
7474
|
+
state: "abc",
|
|
7475
|
+
}),
|
|
7476
|
+
{ headers: { cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S) } },
|
|
7477
|
+
);
|
|
7478
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
7479
|
+
expect(res.status).toBe(302);
|
|
7480
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7481
|
+
expect(loc.searchParams.get("error")).toBe("invalid_scope");
|
|
7482
|
+
expect(loc.searchParams.get("error_description")).toContain("scribe:admin");
|
|
7284
7483
|
} finally {
|
|
7285
7484
|
cleanup();
|
|
7286
7485
|
}
|
|
@@ -10073,6 +10272,62 @@ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
|
|
|
10073
10272
|
}
|
|
10074
10273
|
});
|
|
10075
10274
|
|
|
10275
|
+
// GET render (hub#703, folded into hub#314): a user with MIXED authority —
|
|
10276
|
+
// admin on vault A (role=write → holds admin) but only read on vault B
|
|
10277
|
+
// (direct INSERT role=read) — does NOT see the selector. The user could pick
|
|
10278
|
+
// either vault, but doesn't own (hold admin on) EVERY pickable vault, so the
|
|
10279
|
+
// `userHoldsAdminOnPickable` predicate (`assignedVaults.every(v => verbs
|
|
10280
|
+
// includes "admin")`) fails on vault B and the selector is suppressed. The
|
|
10281
|
+
// suppression logic already ships + is correct (oauth-handlers.ts ~2963 +
|
|
10282
|
+
// ~1226); this test closes the coverage gap with no code change.
|
|
10283
|
+
test("selector NOT rendered for a mixed-authority user (admin on A, read-only on B)", async () => {
|
|
10284
|
+
const { db, cleanup } = await makeDb();
|
|
10285
|
+
try {
|
|
10286
|
+
await createUser(db, "owner", "pw");
|
|
10287
|
+
const mixed = await createUser(db, "mixed", "pw", { allowMulti: true });
|
|
10288
|
+
// Vault A ("work"): role=write → vaultVerbsForRole maps to [read,write,
|
|
10289
|
+
// admin], so the user holds admin on A. (setUserVaults hardcodes write.)
|
|
10290
|
+
setUserVaults(db, mixed.id, ["work"]);
|
|
10291
|
+
// Vault B ("other"): direct INSERT role=read → holds read only, NOT admin.
|
|
10292
|
+
// setUserVaults DELETEs first, so this INSERT must come after it to keep A.
|
|
10293
|
+
db.prepare(
|
|
10294
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
|
|
10295
|
+
).run(mixed.id, "other", new Date().toISOString());
|
|
10296
|
+
const session = createSession(db, { userId: mixed.id });
|
|
10297
|
+
const reg = registerClient(db, {
|
|
10298
|
+
redirectUris: ["https://app.example/cb"],
|
|
10299
|
+
status: "approved",
|
|
10300
|
+
});
|
|
10301
|
+
const { challenge } = makePkce();
|
|
10302
|
+
const res = handleAuthorizeGet(
|
|
10303
|
+
db,
|
|
10304
|
+
new Request(
|
|
10305
|
+
authorizeUrl({
|
|
10306
|
+
client_id: reg.client.clientId,
|
|
10307
|
+
redirect_uri: "https://app.example/cb",
|
|
10308
|
+
response_type: "code",
|
|
10309
|
+
code_challenge: challenge,
|
|
10310
|
+
code_challenge_method: "S256",
|
|
10311
|
+
scope: "vault:read",
|
|
10312
|
+
}),
|
|
10313
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10314
|
+
),
|
|
10315
|
+
selDeps,
|
|
10316
|
+
);
|
|
10317
|
+
expect(res.status).toBe(200);
|
|
10318
|
+
const html = await res.text();
|
|
10319
|
+
// Suppressed: the `.every(v => verbs includes "admin")` check fails on B.
|
|
10320
|
+
expect(html).not.toContain("Access level");
|
|
10321
|
+
expect(html).not.toContain('name="verb_select"');
|
|
10322
|
+
// Sanity: the multi-vault picker DID render (two assigned vaults), so the
|
|
10323
|
+
// suppression is specifically the verb selector, not the whole flow.
|
|
10324
|
+
expect(html).toContain('name="vault_pick" value="work"');
|
|
10325
|
+
expect(html).toContain('name="vault_pick" value="other"');
|
|
10326
|
+
} finally {
|
|
10327
|
+
cleanup();
|
|
10328
|
+
}
|
|
10329
|
+
});
|
|
10330
|
+
|
|
10076
10331
|
// GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
|
|
10077
10332
|
// see the selector — they can't authorize a vault scope at all.
|
|
10078
10333
|
test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
|
|
@@ -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();
|