@openparachute/hub 0.5.13 → 0.5.14-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/api-modules.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Combines three sources into a single per-module row:
|
|
5
5
|
*
|
|
6
|
-
* - **Curated availability** — vault,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* - **Curated availability** — vault, scribe (the launch focus per
|
|
7
|
+
* Aaron 2026-05-27). The list was previously broader; trimmed for
|
|
8
|
+
* the launch arc. The Phase-2 marketplace will broaden this; for
|
|
9
|
+
* now it's hardcoded so the admin UI has a stable "what can I
|
|
10
|
+
* install?" list even on a fresh container where services.json is
|
|
11
|
+
* empty.
|
|
10
12
|
* - **Installed state** — services.json reads (version, installDir).
|
|
11
13
|
* - **Supervisor state** — per-module run status (`running` / `stopped`
|
|
12
14
|
* / `crashed` / `starting` / `restarting`) + pid. Absent when the
|
|
@@ -80,15 +82,30 @@ function lookupModule(
|
|
|
80
82
|
export const API_MODULES_REQUIRED_SCOPE = "parachute:host:auth";
|
|
81
83
|
|
|
82
84
|
/**
|
|
83
|
-
* Curated module short-names
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
85
|
+
* Curated module short-names. The admin UI offers exactly these for install
|
|
86
|
+
* + management. Order is the recommended install order (vault first, scribe
|
|
87
|
+
* second).
|
|
88
|
+
*
|
|
89
|
+
* Trimmed 2026-05-27 (Aaron-directed launch focus) from the prior set of
|
|
90
|
+
* `["vault", "surface", "notes", "scribe", "runner"]`. The dropped modules
|
|
91
|
+
* are still published on npm and still work — they're just not the focus:
|
|
92
|
+
*
|
|
93
|
+
* - `notes` (notes-daemon): retired. Notes-UI now lives at
|
|
94
|
+
* `notes.parachute.computer` as a hosted SPA — operators don't install
|
|
95
|
+
* a notes daemon anymore. The npm package `@openparachute/notes-ui`
|
|
96
|
+
* is a library imported by `parachute-surface` and by custom-surface
|
|
97
|
+
* builders.
|
|
98
|
+
* - `surface` (host module): de-emphasized. `@openparachute/surface-client`
|
|
99
|
+
* remains the canonical library for folks building their own UIs
|
|
100
|
+
* against a Parachute hub; running the surface-host module on your
|
|
101
|
+
* own box is no longer the headline path (use notes.parachute.computer
|
|
102
|
+
* or build your own).
|
|
103
|
+
* - `runner`: experimental, not in the focus set for launch.
|
|
104
|
+
*
|
|
105
|
+
* Re-adding any of these is one line — keep the list small until use
|
|
106
|
+
* cases demand otherwise.
|
|
90
107
|
*/
|
|
91
|
-
export const CURATED_MODULES = ["vault", "
|
|
108
|
+
export const CURATED_MODULES = ["vault", "scribe"] as const;
|
|
92
109
|
export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
|
|
93
110
|
|
|
94
111
|
export interface ApiModulesDeps {
|
|
@@ -385,8 +402,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
385
402
|
// (e.g. `/vault/default` + `/admin/` → `/vault/default/admin/`).
|
|
386
403
|
// - Single-instance modules (app, scribe, runner) declare a
|
|
387
404
|
// full hub-origin path that ALREADY includes the mount
|
|
388
|
-
// (e.g. `/
|
|
389
|
-
// be prepended again or the result is `/app/
|
|
405
|
+
// (e.g. `/surface/admin/`, `/scribe/admin`); the mount must NOT
|
|
406
|
+
// be prepended again or the result is `/app/surface/admin/`
|
|
390
407
|
// (the audit bug caught 2026-05-25 on the SPA's Services
|
|
391
408
|
// dropdown).
|
|
392
409
|
// Detect by checking if candidate is already mount-prefixed.
|
package/src/api-ready.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/ready` — hub-side boot-readiness probe (hub#443).
|
|
3
|
+
*
|
|
4
|
+
* Public (no bearer required) — used by:
|
|
5
|
+
*
|
|
6
|
+
* 1. The transient-state HTML page rendered by the upstream-error
|
|
7
|
+
* flow (see `proxy-error-ui.ts`). Its inline poll script hits this
|
|
8
|
+
* endpoint every 2s up to 5 times so a wizard mid-boot can refresh
|
|
9
|
+
* itself without an HTML reload.
|
|
10
|
+
* 2. Any third-party tool (smoke test, dashboard) that wants to know
|
|
11
|
+
* whether the hub's modules are all up.
|
|
12
|
+
*
|
|
13
|
+
* Shape:
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* "ready": boolean,
|
|
17
|
+
* "ready_modules": string[], // shorts that are up
|
|
18
|
+
* "transient_modules": string[], // shorts currently booting
|
|
19
|
+
* "persistent_modules": string[] // shorts crashed / stopped
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* `ready: true` iff every supervised module is in the "running" state
|
|
23
|
+
* past its boot window AND no module is in transient/persistent
|
|
24
|
+
* failure. The hub itself is implicit — if you reached this endpoint,
|
|
25
|
+
* hub is up.
|
|
26
|
+
*
|
|
27
|
+
* Why public: the page that polls this is itself served pre-auth (a
|
|
28
|
+
* 503 from a proxied request before the operator has even reached
|
|
29
|
+
* /login). Bearer-gating would make the poll fail and the page sit
|
|
30
|
+
* forever on "still loading."
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { DEFAULT_BOOT_WINDOW_MS } from "./proxy-state.ts";
|
|
34
|
+
import type { Supervisor } from "./supervisor.ts";
|
|
35
|
+
|
|
36
|
+
export interface ApiReadyDeps {
|
|
37
|
+
/** Container-mode supervisor handle. When absent the hub is in CLI
|
|
38
|
+
* mode and we report ready=true (we have no visibility into other
|
|
39
|
+
* processes' boot state). */
|
|
40
|
+
supervisor?: Supervisor;
|
|
41
|
+
/** Test seam over Date.now. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
/** Test seam over the boot window. */
|
|
44
|
+
bootWindowMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function handleApiReady(req: Request, deps: ApiReadyDeps = {}): Response {
|
|
48
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
49
|
+
return new Response("method not allowed", { status: 405 });
|
|
50
|
+
}
|
|
51
|
+
const now = (deps.now ?? Date.now)();
|
|
52
|
+
const bootWindow = deps.bootWindowMs ?? DEFAULT_BOOT_WINDOW_MS;
|
|
53
|
+
|
|
54
|
+
const ready: string[] = [];
|
|
55
|
+
const transient: string[] = [];
|
|
56
|
+
const persistent: string[] = [];
|
|
57
|
+
|
|
58
|
+
if (deps.supervisor) {
|
|
59
|
+
for (const m of deps.supervisor.list()) {
|
|
60
|
+
switch (m.status) {
|
|
61
|
+
case "starting":
|
|
62
|
+
case "restarting":
|
|
63
|
+
transient.push(m.short);
|
|
64
|
+
break;
|
|
65
|
+
case "crashed":
|
|
66
|
+
case "stopped":
|
|
67
|
+
persistent.push(m.short);
|
|
68
|
+
break;
|
|
69
|
+
case "running": {
|
|
70
|
+
// Inside the boot window we report transient even though the
|
|
71
|
+
// process is "running" — the listener may not have bound yet.
|
|
72
|
+
// After the window we report ready (process is up + presumed
|
|
73
|
+
// listening; if it's not, the proxy classifier still catches
|
|
74
|
+
// it via the same window check and surfaces persistent state).
|
|
75
|
+
let startedMs = 0;
|
|
76
|
+
if (m.startedAt) {
|
|
77
|
+
const parsed = Date.parse(m.startedAt);
|
|
78
|
+
if (Number.isFinite(parsed)) startedMs = parsed;
|
|
79
|
+
}
|
|
80
|
+
if (startedMs > 0 && now - startedMs < bootWindow) {
|
|
81
|
+
transient.push(m.short);
|
|
82
|
+
} else {
|
|
83
|
+
ready.push(m.short);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isReady = transient.length === 0 && persistent.length === 0;
|
|
92
|
+
const body = JSON.stringify({
|
|
93
|
+
ready: isReady,
|
|
94
|
+
ready_modules: ready,
|
|
95
|
+
transient_modules: transient,
|
|
96
|
+
persistent_modules: persistent,
|
|
97
|
+
});
|
|
98
|
+
return new Response(body, {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
101
|
+
});
|
|
102
|
+
}
|
package/src/api-revoke-token.ts
CHANGED
|
@@ -1,38 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `POST /api/auth/revoke-token` — HTTP companion to `parachute auth
|
|
3
|
-
* revoke-token <jti>` (hub#221) and the
|
|
4
|
-
*
|
|
3
|
+
* revoke-token <jti>` (hub#221) and the backing endpoint for the admin
|
|
4
|
+
* UI's revoke action. Closes hub#220.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Auth — capability attenuation, SYMMETRIC to mint-token (hub#452): you may
|
|
7
|
+
* revoke exactly what you could have minted. After validating the bearer
|
|
8
|
+
* (signature / issuer / expiry — same as today):
|
|
9
|
+
*
|
|
10
|
+
* 1. If the bearer holds `parachute:host:auth` → it may revoke ANY jti
|
|
11
|
+
* (the original, broadest behavior — preserved unchanged).
|
|
12
|
+
* 2. Otherwise the bearer must clear the entry gate — it must hold at least
|
|
13
|
+
* one minting authority (`parachute:host:auth`, `parachute:host:admin`,
|
|
14
|
+
* or some `vault:<*>:admin`, via `hasMintingAuthority`). A bearer with
|
|
15
|
+
* none (e.g. a read-only token) gets 403 up front — it can revoke
|
|
16
|
+
* nothing, just as it can mint nothing.
|
|
17
|
+
* 3. The per-jti authority check then governs what such a bearer may
|
|
18
|
+
* actually revoke: the target jti is revocable iff EVERY one of its
|
|
19
|
+
* recorded scopes satisfies `canGrant(bearerScopes, scope)` — i.e. the
|
|
20
|
+
* bearer could have minted that exact token. A `vault:work:admin` bearer
|
|
21
|
+
* can revoke a `vault:work:write` or `vault:work:admin` jti, but NOT a
|
|
22
|
+
* `vault:other:*` jti and NOT a `parachute:host:*` jti — the same
|
|
23
|
+
* cross-vault / host-escalation walls mint enforces.
|
|
24
|
+
*
|
|
25
|
+
* Idempotency / no-info-leak: an UNKNOWN jti (no `tokens` row — never minted
|
|
26
|
+
* or already purged) returns the SAME 404 `not_found` the endpoint has always
|
|
27
|
+
* returned, for every caller including host:auth. The per-jti authority check
|
|
28
|
+
* only runs when the row is FOUND. So an attenuated bearer probing a jti it
|
|
29
|
+
* doesn't own cannot distinguish "exists but not yours" from "doesn't exist"
|
|
30
|
+
* by the unknown-jti path — it gets the identical 404 a host:auth bearer
|
|
31
|
+
* would. A jti that EXISTS but is out of the bearer's authority returns 403
|
|
32
|
+
* (and is NOT revoked): the caller already knows the jti string, so "exists
|
|
33
|
+
* but not yours" leaks nothing beyond what it already holds — and returning
|
|
34
|
+
* idempotent-ok there would be a lie (it revoked nothing).
|
|
10
35
|
*
|
|
11
36
|
* Body: `{ jti: string }`.
|
|
12
37
|
*
|
|
13
|
-
* Responses (
|
|
14
|
-
* mint-token and the rest of the hub's bearer-protected admin API):
|
|
38
|
+
* Responses (OAuth 2.0 error-shape vocabulary, matching mint-token):
|
|
15
39
|
*
|
|
16
40
|
* - 200 `{ jti, revoked_at }` — success. Idempotent: re-revoking an
|
|
17
|
-
* already-revoked jti returns the existing `revoked_at` and 200
|
|
18
|
-
* same as the CLI's exit-0-with-existing-timestamp behavior.
|
|
41
|
+
* already-revoked jti returns the existing `revoked_at` and 200.
|
|
19
42
|
* - 400 `invalid_request` — missing/malformed body, missing jti.
|
|
20
43
|
* - 401 `unauthenticated` — missing or invalid bearer.
|
|
21
|
-
* - 403 `insufficient_scope` — bearer
|
|
44
|
+
* - 403 `insufficient_scope` — bearer holds no minting authority (entry
|
|
45
|
+
* gate), or the target jti carries a scope the bearer couldn't have
|
|
46
|
+
* minted (per-jti authority check).
|
|
22
47
|
* - 404 `not_found` — no `tokens` row matches the jti.
|
|
23
48
|
* - 405 `method_not_allowed` — non-POST.
|
|
24
49
|
*
|
|
25
50
|
* Identity field in audit-friendly success: not echoed in the response
|
|
26
51
|
* body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
|
|
27
52
|
* is all a UI consumer needs); operator-side audit lives in hub logs.
|
|
28
|
-
* Mirrors the CLI's design where `identity=` was added for stdout but
|
|
29
|
-
* the wire response stays narrow.
|
|
30
53
|
*/
|
|
31
54
|
import type { Database } from "bun:sqlite";
|
|
32
55
|
import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
|
|
56
|
+
import { MINT_HOST_AUTH_SCOPE, canGrant, hasMintingAuthority } from "./scope-attenuation.ts";
|
|
33
57
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Scope that authorises revoking ANY jti unconditionally (rule 1). A bearer
|
|
60
|
+
* without it may still revoke via attenuation (rule 3) if it clears the
|
|
61
|
+
* `hasMintingAuthority` entry gate.
|
|
62
|
+
*/
|
|
63
|
+
export const API_REVOKE_TOKEN_REQUIRED_SCOPE = MINT_HOST_AUTH_SCOPE;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Maximum accepted length of a caller-supplied `jti`. A real jti is a UUID or
|
|
67
|
+
* short opaque token; anything materially longer is malformed input. Capping
|
|
68
|
+
* it keeps the verbatim-echoed value out of structured logs from bloating.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_JTI_LENGTH = 256;
|
|
36
71
|
|
|
37
72
|
export interface ApiRevokeTokenDeps {
|
|
38
73
|
db: Database;
|
|
@@ -80,12 +115,19 @@ export async function handleApiRevokeToken(
|
|
|
80
115
|
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
81
116
|
}
|
|
82
117
|
|
|
83
|
-
// 3.
|
|
84
|
-
|
|
118
|
+
// 3. Entry gate. A `parachute:host:auth` bearer may revoke anything
|
|
119
|
+
// (rule 1) and skips the per-jti authority check below. Any other
|
|
120
|
+
// bearer must hold SOME minting authority (host:admin or a
|
|
121
|
+
// `vault:<*>:admin`) to attempt a revoke at all — a bearer with none
|
|
122
|
+
// can revoke nothing under attenuation, so we 403 it here rather than
|
|
123
|
+
// looking up the jti. Whether such a bearer may revoke a SPECIFIC jti
|
|
124
|
+
// is decided per-jti in step 5 via `canGrant`.
|
|
125
|
+
const bearerHasHostAuth = bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE);
|
|
126
|
+
if (!bearerHasHostAuth && !hasMintingAuthority(bearerScopes)) {
|
|
85
127
|
return jsonError(
|
|
86
128
|
403,
|
|
87
129
|
"insufficient_scope",
|
|
88
|
-
`bearer token
|
|
130
|
+
`bearer token holds no revoke authority (need ${API_REVOKE_TOKEN_REQUIRED_SCOPE}, parachute:host:admin, or vault:<name>:admin)`,
|
|
89
131
|
);
|
|
90
132
|
}
|
|
91
133
|
|
|
@@ -103,15 +145,59 @@ export async function handleApiRevokeToken(
|
|
|
103
145
|
if (typeof body.jti !== "string" || body.jti.length === 0) {
|
|
104
146
|
return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
|
|
105
147
|
}
|
|
148
|
+
// Cap the jti length. It's echoed verbatim into `error_description` and
|
|
149
|
+
// structured log lines; a real jti is a UUID/short token (well under 256
|
|
150
|
+
// chars), so a longer value is malformed input — reject it before it can
|
|
151
|
+
// bloat log lines. JSON-encoded responses already neutralize injection;
|
|
152
|
+
// this is a size guard, not an escaping one.
|
|
153
|
+
if (body.jti.length > MAX_JTI_LENGTH) {
|
|
154
|
+
return jsonError(400, "invalid_request", `jti exceeds ${MAX_JTI_LENGTH}-character maximum`);
|
|
155
|
+
}
|
|
106
156
|
const jti = body.jti;
|
|
107
157
|
|
|
108
|
-
// 5. Lookup + revoke. Order: row-existence first
|
|
109
|
-
//
|
|
110
|
-
//
|
|
158
|
+
// 5. Lookup + per-jti authority + revoke. Order: row-existence first
|
|
159
|
+
// (404 if missing — same response for every caller, no leak), then the
|
|
160
|
+
// attenuation authority check (for non-host:auth bearers), then attempt
|
|
161
|
+
// revoke. Idempotent: if already revoked, surface the existing revoked_at
|
|
162
|
+
// — same CLI semantics from hub#221.
|
|
111
163
|
const existing = findTokenRowByJti(deps.db, jti);
|
|
112
164
|
if (!existing) {
|
|
113
165
|
return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
|
|
114
166
|
}
|
|
167
|
+
|
|
168
|
+
// Per-jti authority (rule 3 / symmetric to mint attenuation). A host:auth
|
|
169
|
+
// bearer skips this — it may revoke anything. Any other bearer may revoke
|
|
170
|
+
// this jti only if EVERY one of its recorded scopes is one the bearer could
|
|
171
|
+
// have minted (`canGrant`). One out-of-authority scope (cross-vault, a
|
|
172
|
+
// host:* scope, etc.) blocks the whole revoke with 403 — and the token is
|
|
173
|
+
// left intact. The caller already knows the jti, so "exists but not yours"
|
|
174
|
+
// leaks nothing beyond what it holds; idempotent-ok would falsely imply a
|
|
175
|
+
// revoke happened.
|
|
176
|
+
if (!bearerHasHostAuth) {
|
|
177
|
+
// A scopeless target (recorded `scopes: []`) would otherwise pass the
|
|
178
|
+
// `canGrant` filter vacuously — `[].filter(...)` is empty, so
|
|
179
|
+
// `ungrantable.length === 0`. That's silently permissive: any bearer
|
|
180
|
+
// clearing the entry gate could revoke a zero-scope token. Such tokens
|
|
181
|
+
// shouldn't exist (the CLI/SPA never mint them), but if one does, only a
|
|
182
|
+
// host:auth bearer may revoke it — a non-host:auth bearer has no
|
|
183
|
+
// attenuation authority that "covers" the empty scope set.
|
|
184
|
+
if (existing.scopes.length === 0) {
|
|
185
|
+
return jsonError(
|
|
186
|
+
403,
|
|
187
|
+
"insufficient_scope",
|
|
188
|
+
`bearer token cannot revoke jti ${jti}: target has no recorded scopes (only ${API_REVOKE_TOKEN_REQUIRED_SCOPE} may revoke a scopeless token)`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const ungrantable = existing.scopes.filter((s) => !canGrant(bearerScopes, s));
|
|
192
|
+
if (ungrantable.length > 0) {
|
|
193
|
+
return jsonError(
|
|
194
|
+
403,
|
|
195
|
+
"insufficient_scope",
|
|
196
|
+
`bearer token cannot revoke jti ${jti}: its scope(s) ${ungrantable.join(", ")} are outside the bearer's authority`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
115
201
|
if (existing.revokedAt) {
|
|
116
202
|
return ok({ jti, revoked_at: existing.revokedAt });
|
|
117
203
|
}
|