@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,426 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
API_MODULES_CHANNEL_REQUIRED_SCOPE,
|
|
7
|
+
API_MODULES_REQUIRED_SCOPE,
|
|
8
|
+
_clearLatestVersionCacheForTests,
|
|
9
|
+
handleApiModules,
|
|
10
|
+
handleApiModulesChannel,
|
|
11
|
+
} from "../api-modules.ts";
|
|
12
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
|
+
import { getSetting, setModuleInstallChannel } from "../hub-settings.ts";
|
|
14
|
+
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
15
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
16
|
+
import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
|
|
17
|
+
import { createUser } from "../users.ts";
|
|
18
|
+
|
|
19
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
20
|
+
|
|
21
|
+
interface Harness {
|
|
22
|
+
dir: string;
|
|
23
|
+
manifestPath: string;
|
|
24
|
+
db: ReturnType<typeof openHubDb>;
|
|
25
|
+
userId: string;
|
|
26
|
+
cleanup: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function makeHarness(): Promise<Harness> {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-modules-"));
|
|
31
|
+
const db = openHubDb(hubDbPath(dir));
|
|
32
|
+
rotateSigningKey(db);
|
|
33
|
+
const user = await createUser(db, "owner", "pw");
|
|
34
|
+
return {
|
|
35
|
+
dir,
|
|
36
|
+
manifestPath: join(dir, "services.json"),
|
|
37
|
+
db,
|
|
38
|
+
userId: user.id,
|
|
39
|
+
cleanup: () => {
|
|
40
|
+
db.close();
|
|
41
|
+
rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
47
|
+
const signed = await signAccessToken(h.db, {
|
|
48
|
+
sub: h.userId,
|
|
49
|
+
scopes,
|
|
50
|
+
audience: "parachute-hub",
|
|
51
|
+
clientId: "parachute-hub",
|
|
52
|
+
issuer: ISSUER,
|
|
53
|
+
ttlSeconds: 3600,
|
|
54
|
+
});
|
|
55
|
+
recordTokenMint(h.db, {
|
|
56
|
+
jti: signed.jti,
|
|
57
|
+
createdVia: "operator_mint",
|
|
58
|
+
subject: h.userId,
|
|
59
|
+
clientId: "parachute-hub",
|
|
60
|
+
scopes,
|
|
61
|
+
expiresAt: signed.expiresAt,
|
|
62
|
+
});
|
|
63
|
+
return signed.token;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeManifest(path: string, services: unknown[]): void {
|
|
67
|
+
writeFileSync(path, JSON.stringify({ services }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getReq(headers: Record<string, string> = {}): Request {
|
|
71
|
+
return new Request("http://localhost/api/modules", {
|
|
72
|
+
method: "GET",
|
|
73
|
+
headers,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function postReq(): Request {
|
|
78
|
+
return new Request("http://localhost/api/modules", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makeIdleSupervisor(): {
|
|
84
|
+
supervisor: Supervisor;
|
|
85
|
+
spawnFn: (req: SpawnRequest) => SupervisedProc;
|
|
86
|
+
} {
|
|
87
|
+
// Test fake: never resolves `exited` so the supervisor's crash-watch
|
|
88
|
+
// loop stays quiet for the test's lifetime.
|
|
89
|
+
const spawnFn: (req: SpawnRequest) => SupervisedProc = () => ({
|
|
90
|
+
pid: 12345,
|
|
91
|
+
exited: new Promise(() => {}),
|
|
92
|
+
stdout: null,
|
|
93
|
+
stderr: null,
|
|
94
|
+
kill: () => {},
|
|
95
|
+
});
|
|
96
|
+
return { supervisor: new Supervisor({ spawnFn }), spawnFn };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("GET /api/modules", () => {
|
|
100
|
+
let h: Harness;
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
h = await makeHarness();
|
|
104
|
+
_clearLatestVersionCacheForTests();
|
|
105
|
+
});
|
|
106
|
+
afterEach(() => h.cleanup());
|
|
107
|
+
|
|
108
|
+
test("405 on non-GET", async () => {
|
|
109
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
110
|
+
const res = await handleApiModules(postReq(), {
|
|
111
|
+
db: h.db,
|
|
112
|
+
issuer: ISSUER,
|
|
113
|
+
manifestPath: h.manifestPath,
|
|
114
|
+
fetchLatestVersion: async () => null,
|
|
115
|
+
});
|
|
116
|
+
expect(res.status).toBe(405);
|
|
117
|
+
// Bearer's not even consulted on method-mismatch — that's fine,
|
|
118
|
+
// 405 short-circuits before auth so we keep the surface defensive.
|
|
119
|
+
expect(bearer).toBeDefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("401 with no Authorization header", async () => {
|
|
123
|
+
const res = await handleApiModules(getReq(), {
|
|
124
|
+
db: h.db,
|
|
125
|
+
issuer: ISSUER,
|
|
126
|
+
manifestPath: h.manifestPath,
|
|
127
|
+
fetchLatestVersion: async () => null,
|
|
128
|
+
});
|
|
129
|
+
expect(res.status).toBe(401);
|
|
130
|
+
const body = (await res.json()) as { error: string };
|
|
131
|
+
expect(body.error).toBe("unauthenticated");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("403 when bearer lacks parachute:host:auth", async () => {
|
|
135
|
+
// A bearer with a narrow scope (`scribe:transcribe`) is valid per
|
|
136
|
+
// signature but must not reach this surface. Insufficient_scope is
|
|
137
|
+
// the spec-shaped error.
|
|
138
|
+
const bearer = await mintBearer(h, ["scribe:transcribe"]);
|
|
139
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
140
|
+
db: h.db,
|
|
141
|
+
issuer: ISSUER,
|
|
142
|
+
manifestPath: h.manifestPath,
|
|
143
|
+
fetchLatestVersion: async () => null,
|
|
144
|
+
});
|
|
145
|
+
expect(res.status).toBe(403);
|
|
146
|
+
const body = (await res.json()) as { error: string };
|
|
147
|
+
expect(body.error).toBe("insufficient_scope");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("200 + curated list on fresh container (empty services.json)", async () => {
|
|
151
|
+
// The v0.6 hot path: brand-new Render container, no services.json
|
|
152
|
+
// yet. UI must render "install vault / notes / scribe" cards even
|
|
153
|
+
// though nothing's installed.
|
|
154
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
155
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
156
|
+
db: h.db,
|
|
157
|
+
issuer: ISSUER,
|
|
158
|
+
manifestPath: h.manifestPath,
|
|
159
|
+
fetchLatestVersion: async () => "0.9.9",
|
|
160
|
+
});
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const body = (await res.json()) as {
|
|
163
|
+
modules: Array<{
|
|
164
|
+
short: string;
|
|
165
|
+
available: boolean;
|
|
166
|
+
installed: boolean;
|
|
167
|
+
latest_version: string | null;
|
|
168
|
+
}>;
|
|
169
|
+
supervisor_available: boolean;
|
|
170
|
+
};
|
|
171
|
+
// Curated order is preserved: vault → notes → scribe.
|
|
172
|
+
expect(body.modules.map((m) => m.short)).toEqual(["vault", "notes", "scribe"]);
|
|
173
|
+
expect(body.modules.every((m) => m.available)).toBe(true);
|
|
174
|
+
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
175
|
+
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
176
|
+
// Supervisor wasn't injected → flag reflects that.
|
|
177
|
+
expect(body.supervisor_available).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("surfaces installed_version from services.json", async () => {
|
|
181
|
+
writeManifest(h.manifestPath, [
|
|
182
|
+
{
|
|
183
|
+
name: "parachute-vault",
|
|
184
|
+
port: 1940,
|
|
185
|
+
paths: ["/vault/default"],
|
|
186
|
+
health: "/vault/default/health",
|
|
187
|
+
version: "0.4.5",
|
|
188
|
+
installDir: "/parachute/modules/node_modules/@openparachute/vault",
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
192
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
193
|
+
db: h.db,
|
|
194
|
+
issuer: ISSUER,
|
|
195
|
+
manifestPath: h.manifestPath,
|
|
196
|
+
fetchLatestVersion: async () => "0.5.0",
|
|
197
|
+
});
|
|
198
|
+
const body = (await res.json()) as {
|
|
199
|
+
modules: Array<{
|
|
200
|
+
short: string;
|
|
201
|
+
installed: boolean;
|
|
202
|
+
installed_version: string | null;
|
|
203
|
+
latest_version: string | null;
|
|
204
|
+
install_dir: string | null;
|
|
205
|
+
}>;
|
|
206
|
+
};
|
|
207
|
+
const vault = body.modules.find((m) => m.short === "vault");
|
|
208
|
+
expect(vault?.installed).toBe(true);
|
|
209
|
+
expect(vault?.installed_version).toBe("0.4.5");
|
|
210
|
+
expect(vault?.latest_version).toBe("0.5.0");
|
|
211
|
+
expect(vault?.install_dir).toBe("/parachute/modules/node_modules/@openparachute/vault");
|
|
212
|
+
// The other curated rows stay installed:false — the test installed
|
|
213
|
+
// only vault, so notes + scribe still render as available-but-not-installed.
|
|
214
|
+
const notes = body.modules.find((m) => m.short === "notes");
|
|
215
|
+
expect(notes?.installed).toBe(false);
|
|
216
|
+
expect(notes?.installed_version).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("includes supervisor status + pid when a supervisor is injected", async () => {
|
|
220
|
+
writeManifest(h.manifestPath, [
|
|
221
|
+
{
|
|
222
|
+
name: "parachute-vault",
|
|
223
|
+
port: 1940,
|
|
224
|
+
paths: ["/vault/default"],
|
|
225
|
+
health: "/vault/default/health",
|
|
226
|
+
version: "0.4.5",
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
const { supervisor } = makeIdleSupervisor();
|
|
230
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
231
|
+
|
|
232
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
233
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
234
|
+
db: h.db,
|
|
235
|
+
issuer: ISSUER,
|
|
236
|
+
manifestPath: h.manifestPath,
|
|
237
|
+
supervisor,
|
|
238
|
+
fetchLatestVersion: async () => null,
|
|
239
|
+
});
|
|
240
|
+
const body = (await res.json()) as {
|
|
241
|
+
modules: Array<{ short: string; supervisor_status: string | null; pid: number | null }>;
|
|
242
|
+
supervisor_available: boolean;
|
|
243
|
+
};
|
|
244
|
+
const vault = body.modules.find((m) => m.short === "vault");
|
|
245
|
+
expect(vault?.supervisor_status).toBe("running");
|
|
246
|
+
expect(vault?.pid).toBe(12345);
|
|
247
|
+
// Modules without a supervisor entry get null status — the UI
|
|
248
|
+
// disables Restart/Stop for those since there's no live process.
|
|
249
|
+
const notes = body.modules.find((m) => m.short === "notes");
|
|
250
|
+
expect(notes?.supervisor_status).toBeNull();
|
|
251
|
+
expect(notes?.pid).toBeNull();
|
|
252
|
+
expect(body.supervisor_available).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("npm probe failure → latest_version is null but response still 200", async () => {
|
|
256
|
+
// The whole point of the probe-is-opportunistic posture: a flaky
|
|
257
|
+
// npm registry must not break the page render. The UI handles
|
|
258
|
+
// null gracefully.
|
|
259
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
260
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
261
|
+
db: h.db,
|
|
262
|
+
issuer: ISSUER,
|
|
263
|
+
manifestPath: h.manifestPath,
|
|
264
|
+
fetchLatestVersion: async () => null,
|
|
265
|
+
});
|
|
266
|
+
expect(res.status).toBe(200);
|
|
267
|
+
const body = (await res.json()) as {
|
|
268
|
+
modules: Array<{ short: string; latest_version: string | null }>;
|
|
269
|
+
};
|
|
270
|
+
expect(body.modules.every((m) => m.latest_version === null)).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("caches latest_version across requests within the TTL", async () => {
|
|
274
|
+
// Second back-to-back request must not re-hit the registry. The
|
|
275
|
+
// UI may poll this endpoint; we don't want it to slam npm.
|
|
276
|
+
let calls = 0;
|
|
277
|
+
const probe = async (_pkg: string): Promise<string | null> => {
|
|
278
|
+
calls++;
|
|
279
|
+
return "0.5.0";
|
|
280
|
+
};
|
|
281
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
282
|
+
const deps = {
|
|
283
|
+
db: h.db,
|
|
284
|
+
issuer: ISSUER,
|
|
285
|
+
manifestPath: h.manifestPath,
|
|
286
|
+
fetchLatestVersion: probe,
|
|
287
|
+
cacheTtlMs: 60_000,
|
|
288
|
+
};
|
|
289
|
+
await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), deps);
|
|
290
|
+
const callsAfterFirst = calls;
|
|
291
|
+
await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), deps);
|
|
292
|
+
expect(callsAfterFirst).toBeGreaterThan(0);
|
|
293
|
+
expect(calls).toBe(callsAfterFirst);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("surfaces module_install_channel in the response (hub#275)", async () => {
|
|
297
|
+
// Default — first read seeds with `latest`.
|
|
298
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
299
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
300
|
+
db: h.db,
|
|
301
|
+
issuer: ISSUER,
|
|
302
|
+
manifestPath: h.manifestPath,
|
|
303
|
+
fetchLatestVersion: async () => null,
|
|
304
|
+
});
|
|
305
|
+
expect(res.status).toBe(200);
|
|
306
|
+
const body = (await res.json()) as { module_install_channel: string };
|
|
307
|
+
expect(body.module_install_channel).toBe("latest");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("module_install_channel reflects toggled value on next GET", async () => {
|
|
311
|
+
setModuleInstallChannel(h.db, "rc");
|
|
312
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
313
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
314
|
+
db: h.db,
|
|
315
|
+
issuer: ISSUER,
|
|
316
|
+
manifestPath: h.manifestPath,
|
|
317
|
+
fetchLatestVersion: async () => null,
|
|
318
|
+
});
|
|
319
|
+
const body = (await res.json()) as { module_install_channel: string };
|
|
320
|
+
expect(body.module_install_channel).toBe("rc");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("PUT /api/modules/channel — hub#275 channel toggle", () => {
|
|
325
|
+
let h: Harness;
|
|
326
|
+
|
|
327
|
+
beforeEach(async () => {
|
|
328
|
+
h = await makeHarness();
|
|
329
|
+
});
|
|
330
|
+
afterEach(() => h.cleanup());
|
|
331
|
+
|
|
332
|
+
function putReq(body: unknown, headers: Record<string, string> = {}): Request {
|
|
333
|
+
return new Request("http://localhost/api/modules/channel", {
|
|
334
|
+
method: "PUT",
|
|
335
|
+
headers: { "content-type": "application/json", ...headers },
|
|
336
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
test("405 on non-PUT", async () => {
|
|
341
|
+
const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
|
|
342
|
+
const res = await handleApiModulesChannel(
|
|
343
|
+
new Request("http://localhost/api/modules/channel", {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
346
|
+
}),
|
|
347
|
+
{ db: h.db, issuer: ISSUER },
|
|
348
|
+
);
|
|
349
|
+
expect(res.status).toBe(405);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("401 on missing bearer", async () => {
|
|
353
|
+
const res = await handleApiModulesChannel(putReq({ channel: "rc" }), {
|
|
354
|
+
db: h.db,
|
|
355
|
+
issuer: ISSUER,
|
|
356
|
+
});
|
|
357
|
+
expect(res.status).toBe(401);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("403 on bearer without parachute:host:admin", async () => {
|
|
361
|
+
// `:host:auth` reads the GET catalog — it must NOT be allowed to
|
|
362
|
+
// flip the install channel. Boundary matches install/upgrade/uninstall.
|
|
363
|
+
const bearer = await mintBearer(h, ["parachute:host:auth"]);
|
|
364
|
+
const res = await handleApiModulesChannel(
|
|
365
|
+
putReq({ channel: "rc" }, { authorization: `Bearer ${bearer}` }),
|
|
366
|
+
{ db: h.db, issuer: ISSUER },
|
|
367
|
+
);
|
|
368
|
+
expect(res.status).toBe(403);
|
|
369
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
370
|
+
expect(body.error).toBe("insufficient_scope");
|
|
371
|
+
expect(body.error_description).toContain("parachute:host:admin");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("400 on malformed body (not JSON)", async () => {
|
|
375
|
+
const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
|
|
376
|
+
const res = await handleApiModulesChannel(
|
|
377
|
+
putReq("not-json", { authorization: `Bearer ${bearer}` }),
|
|
378
|
+
{ db: h.db, issuer: ISSUER },
|
|
379
|
+
);
|
|
380
|
+
expect(res.status).toBe(400);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("400 on invalid channel value", async () => {
|
|
384
|
+
const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
|
|
385
|
+
const res = await handleApiModulesChannel(
|
|
386
|
+
putReq({ channel: "stable" }, { authorization: `Bearer ${bearer}` }),
|
|
387
|
+
{ db: h.db, issuer: ISSUER },
|
|
388
|
+
);
|
|
389
|
+
expect(res.status).toBe(400);
|
|
390
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
391
|
+
expect(body.error).toBe("invalid_channel");
|
|
392
|
+
expect(body.error_description).toMatch(/latest, rc/);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("400 on missing channel field", async () => {
|
|
396
|
+
const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
|
|
397
|
+
const res = await handleApiModulesChannel(
|
|
398
|
+
putReq({ foo: "bar" }, { authorization: `Bearer ${bearer}` }),
|
|
399
|
+
{ db: h.db, issuer: ISSUER },
|
|
400
|
+
);
|
|
401
|
+
expect(res.status).toBe(400);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("200 + writes the new channel to hub_settings", async () => {
|
|
405
|
+
const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
|
|
406
|
+
const res = await handleApiModulesChannel(
|
|
407
|
+
putReq({ channel: "rc" }, { authorization: `Bearer ${bearer}` }),
|
|
408
|
+
{ db: h.db, issuer: ISSUER },
|
|
409
|
+
);
|
|
410
|
+
expect(res.status).toBe(200);
|
|
411
|
+
const body = (await res.json()) as { channel: string };
|
|
412
|
+
expect(body.channel).toBe("rc");
|
|
413
|
+
expect(getSetting(h.db, "module_install_channel")).toBe("rc");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("200 + can toggle back to latest", async () => {
|
|
417
|
+
setModuleInstallChannel(h.db, "rc");
|
|
418
|
+
const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
|
|
419
|
+
const res = await handleApiModulesChannel(
|
|
420
|
+
putReq({ channel: "latest" }, { authorization: `Bearer ${bearer}` }),
|
|
421
|
+
{ db: h.db, issuer: ISSUER },
|
|
422
|
+
);
|
|
423
|
+
expect(res.status).toBe(200);
|
|
424
|
+
expect(getSetting(h.db, "module_install_channel")).toBe("latest");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { handleRevocationList } from "../api-revocation-list.ts";
|
|
6
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { recordTokenMint, revokeTokenByJti, signRefreshToken } from "../jwt-sign.ts";
|
|
8
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
9
|
+
import { createUser } from "../users.ts";
|
|
10
|
+
|
|
11
|
+
interface Harness {
|
|
12
|
+
dir: string;
|
|
13
|
+
cleanup: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeHarness(): Harness {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-revocation-"));
|
|
18
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("GET /.well-known/parachute-revocation.json (hub#212 Phase 1)", () => {
|
|
22
|
+
test("empty list when nothing revoked", async () => {
|
|
23
|
+
const h = makeHarness();
|
|
24
|
+
try {
|
|
25
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
26
|
+
try {
|
|
27
|
+
rotateSigningKey(db);
|
|
28
|
+
const req = new Request("http://localhost/.well-known/parachute-revocation.json");
|
|
29
|
+
const resp = handleRevocationList(req, { db });
|
|
30
|
+
expect(resp.status).toBe(200);
|
|
31
|
+
expect(resp.headers.get("content-type")).toBe("application/json");
|
|
32
|
+
expect(resp.headers.get("cache-control")).toBe("public, max-age=60");
|
|
33
|
+
const body = (await resp.json()) as { generated_at: string; jtis: string[] };
|
|
34
|
+
expect(body.jtis).toEqual([]);
|
|
35
|
+
expect(typeof body.generated_at).toBe("string");
|
|
36
|
+
} finally {
|
|
37
|
+
db.close();
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
h.cleanup();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns revoked jti after revokeTokenByJti", async () => {
|
|
45
|
+
const h = makeHarness();
|
|
46
|
+
try {
|
|
47
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
48
|
+
try {
|
|
49
|
+
rotateSigningKey(db);
|
|
50
|
+
const futureExpiry = new Date(Date.now() + 86400_000).toISOString();
|
|
51
|
+
recordTokenMint(db, {
|
|
52
|
+
jti: "jti-revoked-1",
|
|
53
|
+
createdVia: "cli_mint",
|
|
54
|
+
subject: "operator",
|
|
55
|
+
clientId: "parachute-hub",
|
|
56
|
+
scopes: ["scribe:transcribe"],
|
|
57
|
+
expiresAt: futureExpiry,
|
|
58
|
+
});
|
|
59
|
+
recordTokenMint(db, {
|
|
60
|
+
jti: "jti-active-1",
|
|
61
|
+
createdVia: "cli_mint",
|
|
62
|
+
subject: "operator",
|
|
63
|
+
clientId: "parachute-hub",
|
|
64
|
+
scopes: ["vault:read"],
|
|
65
|
+
expiresAt: futureExpiry,
|
|
66
|
+
});
|
|
67
|
+
revokeTokenByJti(db, "jti-revoked-1", new Date());
|
|
68
|
+
|
|
69
|
+
const req = new Request("http://localhost/.well-known/parachute-revocation.json");
|
|
70
|
+
const resp = handleRevocationList(req, { db });
|
|
71
|
+
expect(resp.status).toBe(200);
|
|
72
|
+
const body = (await resp.json()) as { jtis: string[] };
|
|
73
|
+
expect(body.jtis).toEqual(["jti-revoked-1"]);
|
|
74
|
+
} finally {
|
|
75
|
+
db.close();
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
h.cleanup();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("filters out already-expired revoked jtis", async () => {
|
|
83
|
+
const h = makeHarness();
|
|
84
|
+
try {
|
|
85
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
86
|
+
try {
|
|
87
|
+
rotateSigningKey(db);
|
|
88
|
+
const past = new Date(Date.now() - 86400_000).toISOString();
|
|
89
|
+
const future = new Date(Date.now() + 86400_000).toISOString();
|
|
90
|
+
// Revoked but expired — should NOT appear in the list (consumers'
|
|
91
|
+
// own exp check would reject it anyway; listing it is noise).
|
|
92
|
+
recordTokenMint(db, {
|
|
93
|
+
jti: "jti-expired-revoked",
|
|
94
|
+
createdVia: "cli_mint",
|
|
95
|
+
subject: "operator",
|
|
96
|
+
clientId: "parachute-hub",
|
|
97
|
+
scopes: ["vault:read"],
|
|
98
|
+
expiresAt: past,
|
|
99
|
+
});
|
|
100
|
+
recordTokenMint(db, {
|
|
101
|
+
jti: "jti-active-revoked",
|
|
102
|
+
createdVia: "cli_mint",
|
|
103
|
+
subject: "operator",
|
|
104
|
+
clientId: "parachute-hub",
|
|
105
|
+
scopes: ["vault:read"],
|
|
106
|
+
expiresAt: future,
|
|
107
|
+
});
|
|
108
|
+
revokeTokenByJti(db, "jti-expired-revoked", new Date());
|
|
109
|
+
revokeTokenByJti(db, "jti-active-revoked", new Date());
|
|
110
|
+
|
|
111
|
+
const req = new Request("http://localhost/.well-known/parachute-revocation.json");
|
|
112
|
+
const resp = handleRevocationList(req, { db });
|
|
113
|
+
const body = (await resp.json()) as { jtis: string[] };
|
|
114
|
+
expect(body.jtis).toEqual(["jti-active-revoked"]);
|
|
115
|
+
} finally {
|
|
116
|
+
db.close();
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
h.cleanup();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("OAuth-refresh rows participate in the same revocation surface", async () => {
|
|
124
|
+
const h = makeHarness();
|
|
125
|
+
try {
|
|
126
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
127
|
+
try {
|
|
128
|
+
rotateSigningKey(db);
|
|
129
|
+
const u = await createUser(db, "owner", "pw");
|
|
130
|
+
// signRefreshToken writes a row with created_via='oauth_refresh';
|
|
131
|
+
// revoking by jti must surface it the same way as cli_mint rows.
|
|
132
|
+
const refresh = signRefreshToken(db, {
|
|
133
|
+
jti: "jti-oauth-refresh-1",
|
|
134
|
+
userId: u.id,
|
|
135
|
+
clientId: "parachute-hub",
|
|
136
|
+
scopes: ["vault:read"],
|
|
137
|
+
});
|
|
138
|
+
expect(refresh.familyId).toBeDefined();
|
|
139
|
+
revokeTokenByJti(db, "jti-oauth-refresh-1", new Date());
|
|
140
|
+
|
|
141
|
+
const req = new Request("http://localhost/.well-known/parachute-revocation.json");
|
|
142
|
+
const resp = handleRevocationList(req, { db });
|
|
143
|
+
const body = (await resp.json()) as { jtis: string[] };
|
|
144
|
+
expect(body.jtis).toContain("jti-oauth-refresh-1");
|
|
145
|
+
} finally {
|
|
146
|
+
db.close();
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
h.cleanup();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("rejects non-GET methods with 405", async () => {
|
|
154
|
+
const h = makeHarness();
|
|
155
|
+
try {
|
|
156
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
157
|
+
try {
|
|
158
|
+
rotateSigningKey(db);
|
|
159
|
+
const req = new Request("http://localhost/.well-known/parachute-revocation.json", {
|
|
160
|
+
method: "POST",
|
|
161
|
+
});
|
|
162
|
+
const resp = handleRevocationList(req, { db });
|
|
163
|
+
expect(resp.status).toBe(405);
|
|
164
|
+
} finally {
|
|
165
|
+
db.close();
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
h.cleanup();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("revokeTokenByJti is idempotent — second call returns false", () => {
|
|
173
|
+
const h = makeHarness();
|
|
174
|
+
try {
|
|
175
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
176
|
+
try {
|
|
177
|
+
rotateSigningKey(db);
|
|
178
|
+
const future = new Date(Date.now() + 86400_000).toISOString();
|
|
179
|
+
recordTokenMint(db, {
|
|
180
|
+
jti: "jti-once",
|
|
181
|
+
createdVia: "cli_mint",
|
|
182
|
+
subject: "operator",
|
|
183
|
+
clientId: "parachute-hub",
|
|
184
|
+
scopes: ["vault:read"],
|
|
185
|
+
expiresAt: future,
|
|
186
|
+
});
|
|
187
|
+
const now = new Date();
|
|
188
|
+
expect(revokeTokenByJti(db, "jti-once", now)).toBe(true);
|
|
189
|
+
expect(revokeTokenByJti(db, "jti-once", now)).toBe(false);
|
|
190
|
+
expect(revokeTokenByJti(db, "jti-does-not-exist", now)).toBe(false);
|
|
191
|
+
} finally {
|
|
192
|
+
db.close();
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
h.cleanup();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|