@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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- 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
|
+
}
|