@openparachute/hub 0.5.7 → 0.5.10-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 (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,367 @@
1
+ /**
2
+ * `GET /api/modules` — admin SPA's module-management surface.
3
+ *
4
+ * Combines three sources into a single per-module row:
5
+ *
6
+ * - **Curated availability** — vault, notes, scribe (the v0.6 release
7
+ * bar). The Phase-2 marketplace will broaden this; for now it's
8
+ * hardcoded so the admin UI has a stable "what can I install?" list
9
+ * even on a fresh container where services.json is empty.
10
+ * - **Installed state** — services.json reads (version, installDir).
11
+ * - **Supervisor state** — per-module run status (`running` / `stopped`
12
+ * / `crashed` / `starting` / `restarting`) + pid. Absent when the
13
+ * hub is in CLI mode (no supervisor injected through HubFetchDeps).
14
+ *
15
+ * Bearer-gated on `parachute:host:auth` to match the rest of `/api/auth/*`
16
+ * and `/api/grants` — the admin SPA mints this scope via
17
+ * `/admin/host-admin-token` and threads it as `Authorization: Bearer`.
18
+ *
19
+ * The `latest_version` field is opportunistic: an npm registry probe with
20
+ * a short timeout. On failure it's null and the UI just shows "check
21
+ * later" — we don't fail the whole request because one network blip
22
+ * shouldn't keep the page from rendering installed modules.
23
+ */
24
+
25
+ import type { Database } from "bun:sqlite";
26
+ import {
27
+ type ModuleInstallChannel,
28
+ getModuleInstallChannel,
29
+ isModuleInstallChannel,
30
+ setModuleInstallChannel,
31
+ } from "./hub-settings.ts";
32
+ import { validateAccessToken } from "./jwt-sign.ts";
33
+ import { FIRST_PARTY_FALLBACKS } from "./service-spec.ts";
34
+ import { readManifest } from "./services-manifest.ts";
35
+ import type { ModuleState, Supervisor } from "./supervisor.ts";
36
+
37
+ /** Scope required on the bearer token to call this endpoint. */
38
+ export const API_MODULES_REQUIRED_SCOPE = "parachute:host:auth";
39
+
40
+ /**
41
+ * Curated module short-names for v0.6 Render self-host. Marketplace is
42
+ * Phase 2 — until then, the admin UI offers exactly these three. Order
43
+ * is the recommended install order (vault before notes, scribe last).
44
+ */
45
+ export const CURATED_MODULES = ["vault", "notes", "scribe"] as const;
46
+ export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
47
+
48
+ export interface ApiModulesDeps {
49
+ db: Database;
50
+ issuer: string;
51
+ manifestPath: string;
52
+ supervisor?: Supervisor;
53
+ /**
54
+ * NPM @latest probe. Returns the version string or null on failure /
55
+ * timeout. Default is the real npm registry; tests inject a fake so
56
+ * they don't hit the network.
57
+ */
58
+ fetchLatestVersion?: (pkg: string) => Promise<string | null>;
59
+ /**
60
+ * Module-level cache TTL for `latest_version` probes, in ms. Default
61
+ * 5 minutes — long enough that a tab refresh doesn't slam npm,
62
+ * short enough that an `npm publish` shows up by the next minute the
63
+ * operator clicks Upgrade. Test seam: pass 0 to disable caching.
64
+ */
65
+ cacheTtlMs?: number;
66
+ /** Test seam over wall-clock. */
67
+ now?: () => number;
68
+ }
69
+
70
+ interface ModuleWireShape {
71
+ short: string;
72
+ package: string;
73
+ display_name: string;
74
+ tagline: string;
75
+ available: boolean;
76
+ installed: boolean;
77
+ installed_version: string | null;
78
+ latest_version: string | null;
79
+ supervisor_status: ModuleState["status"] | null;
80
+ pid: number | null;
81
+ /**
82
+ * The path on disk where the module is installed, if known. Surfaces
83
+ * the BUN_INSTALL or bun-link install location for operator debug —
84
+ * the UI can show "installed at /parachute/modules/node_modules/..."
85
+ * so a vanished disk is obvious.
86
+ */
87
+ install_dir: string | null;
88
+ }
89
+
90
+ interface ModulesResponse {
91
+ modules: ModuleWireShape[];
92
+ /**
93
+ * Whether the supervisor is wired into this hub. `false` under
94
+ * `parachute expose` / on-box CLI; the UI greys out install/start
95
+ * actions because the supervisor's the only path that drives them
96
+ * (the on-box `parachute start <svc>` flow lives outside hub).
97
+ */
98
+ supervisor_available: boolean;
99
+ /**
100
+ * Current module install channel (`latest` | `rc`). Surfaced here so
101
+ * the SPA can render the toggle without a second roundtrip. Read on
102
+ * each request — the hub-settings layer is the source of truth, and
103
+ * a toggle change is visible to the next GET without a hub restart
104
+ * (hub#275).
105
+ */
106
+ module_install_channel: ModuleInstallChannel;
107
+ }
108
+
109
+ interface CachedVersion {
110
+ value: string | null;
111
+ fetchedAt: number;
112
+ }
113
+
114
+ const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
115
+ const latestVersionCache = new Map<string, CachedVersion>();
116
+
117
+ /**
118
+ * Default `fetchLatestVersion`. Hits the npm registry's package
119
+ * metadata endpoint with a 3s AbortController timeout. Returns null on
120
+ * any failure (timeout, network, parse, missing dist-tag) — the UI
121
+ * tolerates a missing latest_version, so we keep the response shape
122
+ * stable even when the registry is flaky.
123
+ */
124
+ export async function defaultFetchLatestVersion(pkg: string): Promise<string | null> {
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), 3_000);
127
+ try {
128
+ const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`;
129
+ const res = await fetch(url, { signal: controller.signal });
130
+ if (!res.ok) return null;
131
+ const body = (await res.json()) as { version?: unknown };
132
+ return typeof body.version === "string" ? body.version : null;
133
+ } catch {
134
+ return null;
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+
140
+ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Promise<Response> {
141
+ if (req.method !== "GET") {
142
+ return jsonError(405, "method_not_allowed", "use GET");
143
+ }
144
+
145
+ // Bearer presence + parsing.
146
+ const auth = req.headers.get("authorization");
147
+ if (!auth || !auth.startsWith("Bearer ")) {
148
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
149
+ }
150
+ const bearer = auth.slice("Bearer ".length).trim();
151
+ if (!bearer) {
152
+ return jsonError(401, "unauthenticated", "empty bearer token");
153
+ }
154
+
155
+ // Bearer validation.
156
+ let bearerScopes: string[];
157
+ try {
158
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
159
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
160
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
161
+ }
162
+ bearerScopes =
163
+ typeof validated.payload.scope === "string"
164
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
165
+ : [];
166
+ } catch (err) {
167
+ const msg = err instanceof Error ? err.message : String(err);
168
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
169
+ }
170
+
171
+ if (!bearerScopes.includes(API_MODULES_REQUIRED_SCOPE)) {
172
+ return jsonError(403, "insufficient_scope", `bearer token lacks ${API_MODULES_REQUIRED_SCOPE}`);
173
+ }
174
+
175
+ // Load installed state from services.json. Missing file = empty manifest
176
+ // (fresh container), which is the v0.6 hot path — readManifest already
177
+ // returns { services: [] } for a missing file, so no extra branching.
178
+ const manifest = readManifest(deps.manifestPath);
179
+ const installedByShort = new Map<string, { version: string; installDir?: string }>();
180
+ for (const entry of manifest.services) {
181
+ // The installed-by-short map is keyed on `short` for join against
182
+ // the curated list. shortNameForManifest reads from
183
+ // FIRST_PARTY_FALLBACKS — we walk that table directly to derive the
184
+ // mapping, since `entry.name` is the long manifestName and we want
185
+ // the canonical short here without re-importing the helper.
186
+ for (const short of CURATED_MODULES) {
187
+ const fb = FIRST_PARTY_FALLBACKS[short];
188
+ if (fb?.manifest.manifestName === entry.name) {
189
+ const value: { version: string; installDir?: string } = { version: entry.version };
190
+ if (entry.installDir !== undefined) value.installDir = entry.installDir;
191
+ installedByShort.set(short, value);
192
+ }
193
+ }
194
+ }
195
+
196
+ // Supervisor state — per-module run status snapshot.
197
+ const supervisor = deps.supervisor;
198
+ const stateByShort = new Map<string, ModuleState>();
199
+ if (supervisor) {
200
+ for (const state of supervisor.list()) {
201
+ stateByShort.set(state.short, state);
202
+ }
203
+ }
204
+
205
+ // Resolve npm @latest in parallel — short timeout per request, cache
206
+ // shared across requests so a fast UI poll doesn't slam the registry.
207
+ const fetchLatest = deps.fetchLatestVersion ?? defaultFetchLatestVersion;
208
+ const cacheTtl = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
209
+ const now = deps.now ?? Date.now;
210
+
211
+ const latestByShort = new Map<string, string | null>();
212
+ await Promise.all(
213
+ CURATED_MODULES.map(async (short) => {
214
+ const fb = FIRST_PARTY_FALLBACKS[short];
215
+ if (!fb) {
216
+ latestByShort.set(short, null);
217
+ return;
218
+ }
219
+ const pkg = fb.package;
220
+ const cached = latestVersionCache.get(pkg);
221
+ if (cached && cacheTtl > 0 && now() - cached.fetchedAt < cacheTtl) {
222
+ latestByShort.set(short, cached.value);
223
+ return;
224
+ }
225
+ const value = await fetchLatest(pkg);
226
+ latestVersionCache.set(pkg, { value, fetchedAt: now() });
227
+ latestByShort.set(short, value);
228
+ }),
229
+ );
230
+
231
+ // Compose the wire shape. Curated order is the recommended install order;
232
+ // installed modules outside the curated list (uncommon — only third-party)
233
+ // are appended at the end with `available: false`.
234
+ const modules: ModuleWireShape[] = [];
235
+ for (const short of CURATED_MODULES) {
236
+ const fb = FIRST_PARTY_FALLBACKS[short];
237
+ if (!fb) continue;
238
+ const installed = installedByShort.get(short);
239
+ const state = stateByShort.get(short);
240
+ modules.push({
241
+ short,
242
+ package: fb.package,
243
+ display_name: fb.manifest.displayName ?? fb.manifest.name,
244
+ tagline: fb.manifest.tagline ?? "",
245
+ available: true,
246
+ installed: installed !== undefined,
247
+ installed_version: installed?.version ?? null,
248
+ latest_version: latestByShort.get(short) ?? null,
249
+ supervisor_status: state?.status ?? null,
250
+ pid: state?.pid ?? null,
251
+ install_dir: installed?.installDir ?? null,
252
+ });
253
+ }
254
+
255
+ const body: ModulesResponse = {
256
+ modules,
257
+ supervisor_available: supervisor !== undefined,
258
+ module_install_channel: getModuleInstallChannel(deps.db),
259
+ };
260
+
261
+ return new Response(JSON.stringify(body), {
262
+ status: 200,
263
+ headers: { "content-type": "application/json" },
264
+ });
265
+ }
266
+
267
+ /**
268
+ * `PUT /api/modules/channel` — operator-settable module install channel.
269
+ *
270
+ * Bearer-gated on `parachute:host:admin` (same scope as install/upgrade
271
+ * — destructive-ish operator-only). Body: `{ "channel": "latest" | "rc" }`.
272
+ * Writes through to `hub_settings.module_install_channel`; the next
273
+ * runInstall / runUpgrade reads the new value (no hub restart needed).
274
+ *
275
+ * Why `:host:admin` rather than `:host:auth` (the GET scope): changing
276
+ * the channel is an upstream-state change that affects every subsequent
277
+ * module install + upgrade. Same boundary as a `bun add -g` itself.
278
+ */
279
+ export const API_MODULES_CHANNEL_REQUIRED_SCOPE = "parachute:host:admin";
280
+
281
+ export interface ApiModulesChannelDeps {
282
+ db: Database;
283
+ issuer: string;
284
+ }
285
+
286
+ export async function handleApiModulesChannel(
287
+ req: Request,
288
+ deps: ApiModulesChannelDeps,
289
+ ): Promise<Response> {
290
+ if (req.method !== "PUT") {
291
+ return jsonError(405, "method_not_allowed", "use PUT");
292
+ }
293
+
294
+ // Bearer presence + parsing.
295
+ const auth = req.headers.get("authorization");
296
+ if (!auth || !auth.startsWith("Bearer ")) {
297
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
298
+ }
299
+ const bearer = auth.slice("Bearer ".length).trim();
300
+ if (!bearer) {
301
+ return jsonError(401, "unauthenticated", "empty bearer token");
302
+ }
303
+
304
+ // Bearer validation + scope check.
305
+ try {
306
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
307
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
308
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
309
+ }
310
+ const scopes =
311
+ typeof validated.payload.scope === "string"
312
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
313
+ : [];
314
+ if (!scopes.includes(API_MODULES_CHANNEL_REQUIRED_SCOPE)) {
315
+ return jsonError(
316
+ 403,
317
+ "insufficient_scope",
318
+ `bearer token lacks ${API_MODULES_CHANNEL_REQUIRED_SCOPE}`,
319
+ );
320
+ }
321
+ } catch (err) {
322
+ const msg = err instanceof Error ? err.message : String(err);
323
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
324
+ }
325
+
326
+ // Parse + validate body.
327
+ let parsed: unknown;
328
+ try {
329
+ parsed = await req.json();
330
+ } catch {
331
+ return jsonError(400, "invalid_request", "request body must be JSON");
332
+ }
333
+ if (typeof parsed !== "object" || parsed === null) {
334
+ return jsonError(400, "invalid_request", "request body must be a JSON object");
335
+ }
336
+ const channel = (parsed as { channel?: unknown }).channel;
337
+ if (!isModuleInstallChannel(channel)) {
338
+ return jsonError(
339
+ 400,
340
+ "invalid_channel",
341
+ `channel must be one of: latest, rc (got ${JSON.stringify(channel)})`,
342
+ );
343
+ }
344
+
345
+ setModuleInstallChannel(deps.db, channel);
346
+
347
+ return new Response(JSON.stringify({ channel }), {
348
+ status: 200,
349
+ headers: { "content-type": "application/json" },
350
+ });
351
+ }
352
+
353
+ function jsonError(status: number, code: string, description: string): Response {
354
+ return new Response(JSON.stringify({ error: code, error_description: description }), {
355
+ status,
356
+ headers: { "content-type": "application/json" },
357
+ });
358
+ }
359
+
360
+ /**
361
+ * Reset the in-memory `latest_version` cache. Tests call this between
362
+ * runs to prevent state leakage across test cases; production never
363
+ * needs it (the cache is per-process and short-TTL anyway).
364
+ */
365
+ export function _clearLatestVersionCacheForTests(): void {
366
+ latestVersionCache.clear();
367
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `GET /.well-known/parachute-revocation.json` — public list of revoked,
3
+ * not-yet-expired token jtis. Resource servers (vault, scribe, agent)
4
+ * fetch this on a 60s TTL and reject any presented JWT whose jti appears.
5
+ *
6
+ * Public endpoint (no auth). The list itself is harmless to expose: it's
7
+ * a list of opaque IDs whose only utility is "this token shouldn't be
8
+ * accepted." A leaked list doesn't enable any new attack — at worst, an
9
+ * attacker learns which compromise the operator already cleaned up.
10
+ *
11
+ * Already-expired jtis are filtered out: every consumer checks `exp`
12
+ * itself, so listing expired tokens just bloats the response. The
13
+ * revocation list exists for *unexpired* tokens whose validity got cut
14
+ * short. Once `exp` passes, a row falls off the list naturally.
15
+ *
16
+ * Caching: 60s `Cache-Control: max-age=60` matches the consumer's
17
+ * polling cadence (Phase 4 wires the 60s TTL on the resource-server
18
+ * side). Shorter cache = revocation propagates faster but burns more
19
+ * CPU on this endpoint; 60s is the published convergence target.
20
+ */
21
+ import type { Database } from "bun:sqlite";
22
+ import { listActiveRevocations } from "./jwt-sign.ts";
23
+
24
+ export const REVOCATION_LIST_MOUNT = "/.well-known/parachute-revocation.json";
25
+ /** Consumer cache TTL in seconds. Resource servers should poll on this cadence. */
26
+ export const REVOCATION_LIST_CACHE_SECONDS = 60;
27
+
28
+ export interface RevocationListDeps {
29
+ db: Database;
30
+ /** Test seam for time. */
31
+ now?: () => Date;
32
+ }
33
+
34
+ interface RevocationListBody {
35
+ generated_at: string;
36
+ jtis: string[];
37
+ }
38
+
39
+ export function handleRevocationList(req: Request, deps: RevocationListDeps): Response {
40
+ if (req.method !== "GET") {
41
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), {
42
+ status: 405,
43
+ headers: { "content-type": "application/json" },
44
+ });
45
+ }
46
+ const now = deps.now?.() ?? new Date();
47
+ const jtis = listActiveRevocations(deps.db, now);
48
+ const body: RevocationListBody = {
49
+ generated_at: now.toISOString(),
50
+ jtis,
51
+ };
52
+ return new Response(JSON.stringify(body), {
53
+ status: 200,
54
+ headers: {
55
+ "content-type": "application/json",
56
+ "cache-control": `public, max-age=${REVOCATION_LIST_CACHE_SECONDS}`,
57
+ },
58
+ });
59
+ }
@@ -0,0 +1,153 @@
1
+ /**
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.
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.
10
+ *
11
+ * Body: `{ jti: string }`.
12
+ *
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):
15
+ *
16
+ * - 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.
19
+ * - 400 `invalid_request` — missing/malformed body, missing jti.
20
+ * - 401 `unauthenticated` — missing or invalid bearer.
21
+ * - 403 `insufficient_scope` — bearer lacks `parachute:host:auth`.
22
+ * - 404 `not_found` — no `tokens` row matches the jti.
23
+ * - 405 `method_not_allowed` — non-POST.
24
+ *
25
+ * Identity field in audit-friendly success: not echoed in the response
26
+ * body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
27
+ * 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
+ */
31
+ import type { Database } from "bun:sqlite";
32
+ import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
33
+
34
+ /** Scope required on the bearer token to call this endpoint. */
35
+ export const API_REVOKE_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
36
+
37
+ export interface ApiRevokeTokenDeps {
38
+ db: Database;
39
+ /** Hub origin — used to validate the bearer's `iss`. */
40
+ issuer: string;
41
+ /** Test seam for time. */
42
+ now?: () => Date;
43
+ }
44
+
45
+ interface RevokeTokenRequest {
46
+ jti?: unknown;
47
+ }
48
+
49
+ export async function handleApiRevokeToken(
50
+ req: Request,
51
+ deps: ApiRevokeTokenDeps,
52
+ ): Promise<Response> {
53
+ if (req.method !== "POST") {
54
+ return jsonError(405, "method_not_allowed", "use POST");
55
+ }
56
+
57
+ // 1. Bearer presence + parsing.
58
+ const auth = req.headers.get("authorization");
59
+ if (!auth || !auth.startsWith("Bearer ")) {
60
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
61
+ }
62
+ const bearer = auth.slice("Bearer ".length).trim();
63
+ if (!bearer) {
64
+ return jsonError(401, "unauthenticated", "empty bearer token");
65
+ }
66
+
67
+ // 2. Bearer validation (signature, issuer, expiry, hub-side revocation).
68
+ let bearerScopes: string[];
69
+ try {
70
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
71
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
72
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
73
+ }
74
+ bearerScopes =
75
+ typeof validated.payload.scope === "string"
76
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
77
+ : [];
78
+ } catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
81
+ }
82
+
83
+ // 3. Scope gate.
84
+ if (!bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE)) {
85
+ return jsonError(
86
+ 403,
87
+ "insufficient_scope",
88
+ `bearer token lacks ${API_REVOKE_TOKEN_REQUIRED_SCOPE}`,
89
+ );
90
+ }
91
+
92
+ // 4. Body parsing + field extraction.
93
+ let body: RevokeTokenRequest;
94
+ try {
95
+ body = (await req.json()) as RevokeTokenRequest;
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return jsonError(400, "invalid_request", `body must be valid JSON — ${msg}`);
99
+ }
100
+ if (typeof body !== "object" || body === null) {
101
+ return jsonError(400, "invalid_request", "body must be a JSON object");
102
+ }
103
+ if (typeof body.jti !== "string" || body.jti.length === 0) {
104
+ return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
105
+ }
106
+ const jti = body.jti;
107
+
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.
111
+ const existing = findTokenRowByJti(deps.db, jti);
112
+ if (!existing) {
113
+ return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
114
+ }
115
+ if (existing.revokedAt) {
116
+ return ok({ jti, revoked_at: existing.revokedAt });
117
+ }
118
+
119
+ const now = deps.now?.() ?? new Date();
120
+ const flipped = revokeTokenByJti(deps.db, jti, now);
121
+ if (!flipped) {
122
+ // Race: row vanished or was concurrently revoked between our lookup
123
+ // and the UPDATE. Re-read to surface the now-current revoked_at if
124
+ // someone else won. If still nothing, 404 (the row genuinely went
125
+ // away — a concurrent prune, perhaps).
126
+ const reRead = findTokenRowByJti(deps.db, jti);
127
+ if (reRead?.revokedAt) {
128
+ return ok({ jti, revoked_at: reRead.revokedAt });
129
+ }
130
+ return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
131
+ }
132
+ return ok({ jti, revoked_at: now.toISOString() });
133
+ }
134
+
135
+ function ok(body: { jti: string; revoked_at: string }): Response {
136
+ return new Response(JSON.stringify(body), {
137
+ status: 200,
138
+ headers: {
139
+ "content-type": "application/json",
140
+ "cache-control": "no-store",
141
+ },
142
+ });
143
+ }
144
+
145
+ function jsonError(status: number, error: string, description: string): Response {
146
+ return new Response(JSON.stringify({ error, error_description: description }), {
147
+ status,
148
+ headers: {
149
+ "content-type": "application/json",
150
+ "cache-control": "no-store",
151
+ },
152
+ });
153
+ }