@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.
Files changed (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * Combines three sources into a single per-module row:
5
5
  *
6
- * - **Curated availability** — vault, notes, scribe, runner (the v0.6
7
- * release bar). The Phase-2 marketplace will broaden this; for now
8
- * it's hardcoded so the admin UI has a stable "what can I install?"
9
- * list even on a fresh container where services.json is empty.
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 for v0.6 Render self-host. Marketplace is
84
- * Phase 2 until then, the admin UI offers exactly these. Order is the
85
- * recommended install order (vault → app → notes → scribe → runner;
86
- * app auto-bootstraps notes-ui on first boot — `notes` here is the
87
- * notes-daemon back-compat install path retained for operators still on
88
- * the pre-app architecture; scribe + runner come last because they
89
- * depend on a working vault + app to be useful).
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", "app", "notes", "scribe", "runner"] as const;
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. `/app/admin/`, `/scribe/admin`); the mount must NOT
389
- // be prepended again or the result is `/app/app/admin/`
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.
@@ -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
+ }
@@ -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 missing piece behind the future
4
- * admin UI's revoke action.
3
+ * revoke-token <jti>` (hub#221) and the backing endpoint for the admin
4
+ * UI's revoke action. Closes hub#220.
5
5
  *
6
- * Same auth shape as `POST /api/auth/mint-token`: bearer-gated on
7
- * `parachute:host:auth` (admin scope-set tokens carry it as a superset;
8
- * narrow `--scope-set auth` operator tokens carry it directly). Closes
9
- * hub#220.
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 (matching the OAuth 2.0 error-shape vocabulary used by
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 lacks `parachute:host:auth`.
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
- /** Scope required on the bearer token to call this endpoint. */
35
- export const API_REVOKE_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
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. Scope gate.
84
- if (!bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE)) {
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 lacks ${API_REVOKE_TOKEN_REQUIRED_SCOPE}`,
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 (404 if missing), then
109
- // attempt revoke. Idempotent: if already revoked, surface the existing
110
- // revoked_at same CLI semantics from hub#221.
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
  }