@openparachute/hub 0.6.5-rc.8 → 0.7.1

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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -1,882 +0,0 @@
1
- /**
2
- * Tests for `/api/modules/:short/config[/schema]` — admin-SPA module-config
3
- * surface (hub#260).
4
- *
5
- * Coverage:
6
- * - path parser: shape + curated-only
7
- * - auth: 401 / 403 / 405 boundary
8
- * - module-not-installed → 404 with "module_not_installed" code
9
- * - module without config schema (upstream 404) → "no_config_schema"
10
- * - mint-and-forward (Option A): SPA bearer dropped, `<short>:admin`
11
- * proxy bearer carried upstream; verified by decoding the JWT the
12
- * fake upstream receives
13
- * - GET schema / GET values / PUT values pass through verbatim
14
- * - upstream unreachable → 502
15
- * - stripPrefix true (scribe-shape) vs false (notes-shape) → correct
16
- * upstream path
17
- * - 4xx upstream body forwarded verbatim so SPA can render module's
18
- * validation message inline
19
- */
20
-
21
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
- import { tmpdir } from "node:os";
24
- import { join } from "node:path";
25
- import { decodeJwt } from "jose";
26
- import type { CuratedModuleShort } from "../api-modules.ts";
27
- import {
28
- API_MODULES_CONFIG_REQUIRED_SCOPE,
29
- MODULE_CONFIG_PROXY_CLIENT_ID,
30
- handleApiModulesConfig,
31
- parseModulesConfigPath,
32
- } from "../api-modules-config.ts";
33
- import { hubDbPath, openHubDb } from "../hub-db.ts";
34
- import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
35
- import { rotateSigningKey } from "../signing-keys.ts";
36
- import { createUser } from "../users.ts";
37
-
38
- const ISSUER = "http://127.0.0.1:1939";
39
-
40
- interface Harness {
41
- dir: string;
42
- manifestPath: string;
43
- db: ReturnType<typeof openHubDb>;
44
- userId: string;
45
- cleanup: () => void;
46
- }
47
-
48
- async function makeHarness(): Promise<Harness> {
49
- const dir = mkdtempSync(join(tmpdir(), "phub-api-modules-config-"));
50
- const db = openHubDb(hubDbPath(dir));
51
- rotateSigningKey(db);
52
- const user = await createUser(db, "owner", "pw");
53
- return {
54
- dir,
55
- manifestPath: join(dir, "services.json"),
56
- db,
57
- userId: user.id,
58
- cleanup: () => {
59
- db.close();
60
- rmSync(dir, { recursive: true, force: true });
61
- },
62
- };
63
- }
64
-
65
- async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
66
- const signed = await signAccessToken(h.db, {
67
- sub: h.userId,
68
- scopes,
69
- audience: "parachute-hub",
70
- clientId: "parachute-hub",
71
- issuer: ISSUER,
72
- ttlSeconds: 3600,
73
- });
74
- recordTokenMint(h.db, {
75
- jti: signed.jti,
76
- createdVia: "operator_mint",
77
- subject: h.userId,
78
- clientId: "parachute-hub",
79
- scopes,
80
- expiresAt: signed.expiresAt,
81
- });
82
- return signed.token;
83
- }
84
-
85
- function writeManifest(path: string, services: unknown[]): void {
86
- writeFileSync(path, JSON.stringify({ services }));
87
- }
88
-
89
- function makeReq(
90
- url: string,
91
- init: { method?: string; headers?: Record<string, string>; body?: string } = {},
92
- ): Request {
93
- return new Request(`http://localhost${url}`, {
94
- method: init.method ?? "GET",
95
- headers: init.headers,
96
- body: init.body,
97
- });
98
- }
99
-
100
- /**
101
- * Fake upstream fetch: records every call (so tests can assert on the
102
- * URL, method, and Authorization header forwarded) and returns a
103
- * canned Response.
104
- */
105
- function makeFakeUpstream(responder: (url: string, init: RequestInit) => Response): {
106
- fetchFn: (url: string, init: RequestInit) => Promise<Response>;
107
- calls: Array<{ url: string; method: string; authorization: string | null; body: string | null }>;
108
- } {
109
- const calls: Array<{
110
- url: string;
111
- method: string;
112
- authorization: string | null;
113
- body: string | null;
114
- }> = [];
115
- return {
116
- fetchFn: async (url, init) => {
117
- const headers = new Headers(init.headers);
118
- let body: string | null = null;
119
- if (init.body && typeof init.body === "string") body = init.body;
120
- else if (init.body) {
121
- try {
122
- // ReadableStream from forwarded req.body — drain via Response
123
- // for inspectability in tests.
124
- body = await new Response(init.body as ReadableStream<Uint8Array> | null).text();
125
- } catch {
126
- body = null;
127
- }
128
- }
129
- calls.push({
130
- url,
131
- method: init.method ?? "GET",
132
- authorization: headers.get("authorization"),
133
- body,
134
- });
135
- return responder(url, init);
136
- },
137
- calls,
138
- };
139
- }
140
-
141
- describe("parseModulesConfigPath", () => {
142
- test("matches /api/modules/<short>/config", () => {
143
- expect(parseModulesConfigPath("/api/modules/scribe/config")).toEqual({
144
- short: "scribe",
145
- suffix: "",
146
- });
147
- });
148
-
149
- test("matches /api/modules/<short>/config/schema", () => {
150
- expect(parseModulesConfigPath("/api/modules/scribe/config/schema")).toEqual({
151
- short: "scribe",
152
- suffix: "schema",
153
- });
154
- });
155
-
156
- test("matches vault and scribe (curated modules)", () => {
157
- expect(parseModulesConfigPath("/api/modules/vault/config")?.short).toBe("vault");
158
- expect(parseModulesConfigPath("/api/modules/scribe/config/schema")?.short).toBe("scribe");
159
- });
160
-
161
- test("rejects unknown short (non-curated)", () => {
162
- expect(parseModulesConfigPath("/api/modules/unknown/config")).toBeUndefined();
163
- expect(parseModulesConfigPath("/api/modules/channel/config")).toBeUndefined();
164
- // Curated list trimmed 2026-05-27: notes / runner / surface are no
165
- // longer curated and reject at the parse boundary.
166
- expect(parseModulesConfigPath("/api/modules/notes/config")).toBeUndefined();
167
- expect(parseModulesConfigPath("/api/modules/runner/config")).toBeUndefined();
168
- expect(parseModulesConfigPath("/api/modules/surface/config")).toBeUndefined();
169
- });
170
-
171
- test("rejects non-config suffix shapes", () => {
172
- expect(parseModulesConfigPath("/api/modules/scribe/install")).toBeUndefined();
173
- expect(parseModulesConfigPath("/api/modules/scribe/config/extra")).toBeUndefined();
174
- expect(parseModulesConfigPath("/api/modules/scribe")).toBeUndefined();
175
- expect(parseModulesConfigPath("/api/modules/scribe/")).toBeUndefined();
176
- });
177
-
178
- test("rejects non-/api/modules prefixes", () => {
179
- expect(parseModulesConfigPath("/api/auth/tokens")).toBeUndefined();
180
- expect(parseModulesConfigPath("/admin/modules/scribe/config")).toBeUndefined();
181
- });
182
- });
183
-
184
- describe("handleApiModulesConfig — auth", () => {
185
- let h: Harness;
186
- beforeEach(async () => {
187
- h = await makeHarness();
188
- });
189
- afterEach(() => h.cleanup());
190
-
191
- test("405 on POST", async () => {
192
- const res = await handleApiModulesConfig(
193
- makeReq("/api/modules/scribe/config", { method: "POST" }),
194
- { short: "scribe", suffix: "" },
195
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
196
- );
197
- expect(res.status).toBe(405);
198
- });
199
-
200
- test("405 on PUT to /schema", async () => {
201
- const res = await handleApiModulesConfig(
202
- makeReq("/api/modules/scribe/config/schema", { method: "PUT" }),
203
- { short: "scribe", suffix: "schema" },
204
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
205
- );
206
- expect(res.status).toBe(405);
207
- });
208
-
209
- test("401 with no Authorization header", async () => {
210
- const res = await handleApiModulesConfig(
211
- makeReq("/api/modules/scribe/config"),
212
- { short: "scribe", suffix: "" },
213
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
214
- );
215
- expect(res.status).toBe(401);
216
- const body = (await res.json()) as { error: string };
217
- expect(body.error).toBe("unauthenticated");
218
- });
219
-
220
- test("403 when bearer lacks parachute:host:admin", async () => {
221
- const bearer = await mintBearer(h, ["parachute:host:auth"]);
222
- const res = await handleApiModulesConfig(
223
- makeReq("/api/modules/scribe/config", { headers: { authorization: `Bearer ${bearer}` } }),
224
- { short: "scribe", suffix: "" },
225
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
226
- );
227
- expect(res.status).toBe(403);
228
- const body = (await res.json()) as { error: string; error_description: string };
229
- expect(body.error).toBe("insufficient_scope");
230
- expect(body.error_description).toContain(API_MODULES_CONFIG_REQUIRED_SCOPE);
231
- });
232
- });
233
-
234
- describe("handleApiModulesConfig — module-not-installed", () => {
235
- let h: Harness;
236
- beforeEach(async () => {
237
- h = await makeHarness();
238
- // No manifest file written → readManifest returns empty services list.
239
- });
240
- afterEach(() => h.cleanup());
241
-
242
- test("404 module_not_installed when scribe absent from services.json", async () => {
243
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
244
- const res = await handleApiModulesConfig(
245
- makeReq("/api/modules/scribe/config/schema", {
246
- headers: { authorization: `Bearer ${bearer}` },
247
- }),
248
- { short: "scribe", suffix: "schema" },
249
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
250
- );
251
- expect(res.status).toBe(404);
252
- const body = (await res.json()) as { error: string };
253
- expect(body.error).toBe("module_not_installed");
254
- });
255
- });
256
-
257
- /**
258
- * Regression suite for hub#310 — vault / scribe / runner retired their
259
- * FIRST_PARTY_FALLBACKS entries because each module now self-registers its
260
- * services.json row at boot (vault#356, scribe#50, runner#3). The contract:
261
- *
262
- * - **services.json has a row** → operations work using its fields
263
- * (operator-authoritative).
264
- * - **services.json has no row** → `module_not_installed` 404. Hub no
265
- * longer falls back to vendored manifest data — pretending a module is
266
- * installed when it isn't was the anti-pattern we're retiring.
267
- *
268
- * These tests pin both halves of that contract per FALLBACK-retired short
269
- * (vault / scribe / runner) so a future re-introduction of vendored data
270
- * would have to explicitly delete them.
271
- */
272
- describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
273
- let h: Harness;
274
- beforeEach(async () => {
275
- h = await makeHarness();
276
- });
277
- afterEach(() => h.cleanup());
278
-
279
- test("vault not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
280
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
281
- const res = await handleApiModulesConfig(
282
- makeReq("/api/modules/vault/config/schema", {
283
- headers: { authorization: `Bearer ${bearer}` },
284
- }),
285
- { short: "vault", suffix: "schema" },
286
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
287
- );
288
- expect(res.status).toBe(404);
289
- expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
290
- });
291
-
292
- test("scribe not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
293
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
294
- const res = await handleApiModulesConfig(
295
- makeReq("/api/modules/scribe/config/schema", {
296
- headers: { authorization: `Bearer ${bearer}` },
297
- }),
298
- { short: "scribe", suffix: "schema" },
299
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
300
- );
301
- expect(res.status).toBe(404);
302
- expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
303
- });
304
-
305
- test("runner not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
306
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
307
- const res = await handleApiModulesConfig(
308
- makeReq("/api/modules/runner/config/schema", {
309
- headers: { authorization: `Bearer ${bearer}` },
310
- }),
311
- { short: "runner" as CuratedModuleShort, suffix: "schema" },
312
- { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
313
- );
314
- expect(res.status).toBe(404);
315
- expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
316
- });
317
-
318
- test("vault in services.json with self-registered fields → upstream URL composed from entry", async () => {
319
- // Self-registered vault row (mirrors what vault#356's `selfRegister` writes):
320
- // installDir + canonical paths + version + stripPrefix omitted (vault doesn't
321
- // strip). The config proxy must build `/vault/default/.parachute/config/schema`
322
- // — vault's per-mount routing requires the prefix.
323
- writeManifest(h.manifestPath, [
324
- {
325
- name: "parachute-vault",
326
- port: 1940,
327
- paths: ["/vault/default"],
328
- health: "/vault/default/health",
329
- version: "0.4.8-rc.4",
330
- installDir: "/parachute/modules/node_modules/@openparachute/vault",
331
- },
332
- ]);
333
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
334
- const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
335
- const res = await handleApiModulesConfig(
336
- makeReq("/api/modules/vault/config/schema", {
337
- headers: { authorization: `Bearer ${bearer}` },
338
- }),
339
- { short: "vault", suffix: "schema" },
340
- {
341
- db: h.db,
342
- issuer: ISSUER,
343
- manifestPath: h.manifestPath,
344
- upstreamFetch: upstream.fetchFn,
345
- },
346
- );
347
- expect(res.status).toBe(200);
348
- expect(upstream.calls[0]?.url).toBe(
349
- "http://127.0.0.1:1940/vault/default/.parachute/config/schema",
350
- );
351
- });
352
-
353
- test("runner in services.json with self-registered fields → routes to bare /.parachute path", async () => {
354
- // Self-registered runner row (mirrors what runner#3's `selfRegister` writes):
355
- // multi-path declaration with `/.parachute` second → hub#307 routes the
356
- // config proxy to the bare URL regardless of stripPrefix.
357
- writeManifest(h.manifestPath, [
358
- {
359
- name: "parachute-runner",
360
- port: 1945,
361
- paths: ["/runner", "/.parachute"],
362
- health: "/runner/healthz",
363
- version: "0.1.0-rc.4",
364
- stripPrefix: false,
365
- installDir: "/parachute/modules/node_modules/@openparachute/runner",
366
- },
367
- ]);
368
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
369
- const upstream = makeFakeUpstream(() => Response.json({ type: "object" }));
370
- const res = await handleApiModulesConfig(
371
- makeReq("/api/modules/runner/config/schema", {
372
- headers: { authorization: `Bearer ${bearer}` },
373
- }),
374
- { short: "runner" as CuratedModuleShort, suffix: "schema" },
375
- {
376
- db: h.db,
377
- issuer: ISSUER,
378
- manifestPath: h.manifestPath,
379
- upstreamFetch: upstream.fetchFn,
380
- },
381
- );
382
- expect(res.status).toBe(200);
383
- // Bare path — runner hosts /.parachute at root regardless of stripPrefix.
384
- expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
385
- });
386
- });
387
-
388
- describe("handleApiModulesConfig — proxy + mint", () => {
389
- let h: Harness;
390
- beforeEach(async () => {
391
- h = await makeHarness();
392
- // Scribe at port 1943 with `/scribe` mount + `stripPrefix: true`.
393
- // Post hub#310 (vault/scribe/runner FALLBACK retirement), services.json
394
- // is the authoritative source for `stripPrefix` — scribe#50 self-
395
- // registers the flag at boot, so the canonical post-self-register row
396
- // carries it. Verified upstream paths must be the bare
397
- // `/.parachute/config[/schema]` shape.
398
- writeManifest(h.manifestPath, [
399
- {
400
- name: "parachute-scribe",
401
- port: 1943,
402
- paths: ["/scribe"],
403
- health: "/health",
404
- version: "0.4.4-rc.4",
405
- stripPrefix: true,
406
- },
407
- ]);
408
- });
409
- afterEach(() => h.cleanup());
410
-
411
- test("GET /schema mints <short>:admin bearer, drops SPA bearer, hits bare path", async () => {
412
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
413
- const upstream = makeFakeUpstream(() =>
414
- Response.json({ type: "object", properties: { transcribeProvider: { type: "string" } } }),
415
- );
416
- const res = await handleApiModulesConfig(
417
- makeReq("/api/modules/scribe/config/schema", {
418
- headers: { authorization: `Bearer ${bearer}` },
419
- }),
420
- { short: "scribe", suffix: "schema" },
421
- {
422
- db: h.db,
423
- issuer: ISSUER,
424
- manifestPath: h.manifestPath,
425
- upstreamFetch: upstream.fetchFn,
426
- },
427
- );
428
- expect(res.status).toBe(200);
429
- const body = (await res.json()) as { type: string };
430
- expect(body.type).toBe("object");
431
-
432
- // Exactly one upstream call.
433
- expect(upstream.calls).toHaveLength(1);
434
- const call = upstream.calls[0];
435
- if (!call) throw new Error("upstream not called");
436
-
437
- // Correct URL: scribe is stripPrefix-true, so the upstream sees the
438
- // bare `/.parachute/config/schema` — no `/scribe` prefix.
439
- expect(call.url).toBe("http://127.0.0.1:1943/.parachute/config/schema");
440
- expect(call.method).toBe("GET");
441
-
442
- // Authorization is the minted proxy token, NOT the SPA bearer.
443
- expect(call.authorization).toBeString();
444
- expect(call.authorization).not.toBe(`Bearer ${bearer}`);
445
- const proxyJwt = call.authorization?.replace(/^Bearer /, "") ?? "";
446
- const claims = decodeJwt(proxyJwt);
447
- // Per-module scope (`scribe:admin`), per-module audience, correct issuer.
448
- expect(claims.scope).toBe("scribe:admin");
449
- expect(claims.aud).toBe("scribe");
450
- expect(claims.iss).toBe(ISSUER);
451
- expect(claims.client_id).toBe(MODULE_CONFIG_PROXY_CLIENT_ID);
452
- expect(claims.sub).toBe(h.userId);
453
- // Short TTL — exp should be ~60s out from iat. Tolerate small drift.
454
- if (typeof claims.iat === "number" && typeof claims.exp === "number") {
455
- expect(claims.exp - claims.iat).toBe(60);
456
- } else {
457
- throw new Error("proxy JWT missing iat/exp claims");
458
- }
459
- });
460
-
461
- test("GET values returns upstream body verbatim", async () => {
462
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
463
- const upstream = makeFakeUpstream(() =>
464
- Response.json({ transcribeProvider: "parakeet-mlx", cleanupProvider: "none" }),
465
- );
466
- const res = await handleApiModulesConfig(
467
- makeReq("/api/modules/scribe/config", {
468
- headers: { authorization: `Bearer ${bearer}` },
469
- }),
470
- { short: "scribe", suffix: "" },
471
- {
472
- db: h.db,
473
- issuer: ISSUER,
474
- manifestPath: h.manifestPath,
475
- upstreamFetch: upstream.fetchFn,
476
- },
477
- );
478
- expect(res.status).toBe(200);
479
- const body = (await res.json()) as { transcribeProvider: string };
480
- expect(body.transcribeProvider).toBe("parakeet-mlx");
481
- // Bare `/.parachute/config` (no /schema).
482
- expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1943/.parachute/config");
483
- });
484
-
485
- test("PUT forwards body + uses PUT method", async () => {
486
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
487
- const upstream = makeFakeUpstream(() =>
488
- Response.json({ restart_required: ["transcribeProvider"] }),
489
- );
490
- const res = await handleApiModulesConfig(
491
- makeReq("/api/modules/scribe/config", {
492
- method: "PUT",
493
- headers: {
494
- authorization: `Bearer ${bearer}`,
495
- "content-type": "application/json",
496
- },
497
- body: JSON.stringify({ transcribeProvider: "groq" }),
498
- }),
499
- { short: "scribe", suffix: "" },
500
- {
501
- db: h.db,
502
- issuer: ISSUER,
503
- manifestPath: h.manifestPath,
504
- upstreamFetch: upstream.fetchFn,
505
- },
506
- );
507
- expect(res.status).toBe(200);
508
- const body = (await res.json()) as { restart_required: string[] };
509
- expect(body.restart_required).toEqual(["transcribeProvider"]);
510
-
511
- const call = upstream.calls[0];
512
- if (!call) throw new Error("upstream not called");
513
- expect(call.method).toBe("PUT");
514
- expect(call.body).toBe(JSON.stringify({ transcribeProvider: "groq" }));
515
- });
516
-
517
- test("4xx upstream body forwarded verbatim", async () => {
518
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
519
- const upstream = makeFakeUpstream(() =>
520
- Response.json(
521
- {
522
- error: "validation_failed",
523
- message: "transcribeProvider: must be one of [parakeet-mlx, ...]",
524
- errors: [{ path: "transcribeProvider", message: "invalid enum" }],
525
- },
526
- { status: 400 },
527
- ),
528
- );
529
- const res = await handleApiModulesConfig(
530
- makeReq("/api/modules/scribe/config", {
531
- method: "PUT",
532
- headers: { authorization: `Bearer ${bearer}` },
533
- body: JSON.stringify({ transcribeProvider: "bogus" }),
534
- }),
535
- { short: "scribe", suffix: "" },
536
- {
537
- db: h.db,
538
- issuer: ISSUER,
539
- manifestPath: h.manifestPath,
540
- upstreamFetch: upstream.fetchFn,
541
- },
542
- );
543
- expect(res.status).toBe(400);
544
- const body = (await res.json()) as { error: string; errors: unknown[] };
545
- expect(body.error).toBe("validation_failed");
546
- expect(body.errors).toBeArrayOfSize(1);
547
- });
548
-
549
- test("upstream 404 surfaces as no_config_schema (graceful empty-state hint)", async () => {
550
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
551
- const upstream = makeFakeUpstream(() => new Response("not found", { status: 404 }));
552
- const res = await handleApiModulesConfig(
553
- makeReq("/api/modules/scribe/config/schema", {
554
- headers: { authorization: `Bearer ${bearer}` },
555
- }),
556
- { short: "scribe", suffix: "schema" },
557
- {
558
- db: h.db,
559
- issuer: ISSUER,
560
- manifestPath: h.manifestPath,
561
- upstreamFetch: upstream.fetchFn,
562
- },
563
- );
564
- expect(res.status).toBe(404);
565
- const body = (await res.json()) as { error: string };
566
- expect(body.error).toBe("no_config_schema");
567
- });
568
-
569
- test("upstream unreachable → 502", async () => {
570
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
571
- const upstream = makeFakeUpstream(() => {
572
- throw new Error("ECONNREFUSED");
573
- });
574
- const res = await handleApiModulesConfig(
575
- makeReq("/api/modules/scribe/config/schema", {
576
- headers: { authorization: `Bearer ${bearer}` },
577
- }),
578
- { short: "scribe", suffix: "schema" },
579
- {
580
- db: h.db,
581
- issuer: ISSUER,
582
- manifestPath: h.manifestPath,
583
- upstreamFetch: async () => {
584
- // Wrapper to allow capturing the throw cleanly.
585
- await upstream.fetchFn("http://127.0.0.1:1943/.parachute/config/schema", {});
586
- throw new Error("unreachable");
587
- },
588
- },
589
- );
590
- expect(res.status).toBe(502);
591
- const body = (await res.json()) as { error: string };
592
- expect(body.error).toBe("upstream_unreachable");
593
- });
594
- });
595
-
596
- describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
597
- let h: Harness;
598
- beforeEach(async () => {
599
- h = await makeHarness();
600
- // Notes is keep-prefix in FIRST_PARTY_FALLBACKS — the upstream URL
601
- // should preserve the `/notes` mount. (Hub's notes-serve stub doesn't
602
- // expose .parachute/config/schema today; this test only asserts the
603
- // proxy URL shape, not the upstream's behavior.)
604
- writeManifest(h.manifestPath, [
605
- {
606
- name: "parachute-notes",
607
- port: 1941,
608
- paths: ["/notes"],
609
- health: "/health",
610
- version: "0.5.0",
611
- },
612
- ]);
613
- });
614
- afterEach(() => h.cleanup());
615
-
616
- test("keep-prefix module → upstream path includes the mount", async () => {
617
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
618
- const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
619
- await handleApiModulesConfig(
620
- makeReq("/api/modules/notes/config/schema", {
621
- headers: { authorization: `Bearer ${bearer}` },
622
- }),
623
- { short: "notes" as CuratedModuleShort, suffix: "schema" },
624
- {
625
- db: h.db,
626
- issuer: ISSUER,
627
- manifestPath: h.manifestPath,
628
- upstreamFetch: upstream.fetchFn,
629
- },
630
- );
631
- expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1941/notes/.parachute/config/schema");
632
- });
633
- });
634
-
635
- /**
636
- * hub#307: modules that declare `/.parachute` in their `paths[]` host the
637
- * universal protocol endpoints at the bare URL — runner is the first
638
- * example. Before this fix the proxy built `/runner/.parachute/config`
639
- * (mount-prefixed because stripPrefix is false) and runner returned 404.
640
- *
641
- * The fix detects the `/.parachute` declaration in `paths[]` and routes
642
- * to the bare URL regardless of `stripPrefix`. These tests pin that
643
- * behavior + verify vault (mount-routed per-vault) keeps its prefixed
644
- * path so the fix doesn't regress vault config.
645
- */
646
- describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
647
- let h: Harness;
648
- beforeEach(async () => {
649
- h = await makeHarness();
650
- });
651
- afterEach(() => h.cleanup());
652
-
653
- test("runner (stripPrefix:false + /.parachute in paths) → bare /.parachute/config", async () => {
654
- // Runner's FIRST_PARTY_FALLBACKS shape: paths includes `/.parachute`
655
- // explicitly because runner serves the universal protocol at the bare
656
- // URL. The services.json entry can carry either path first; we put
657
- // `/runner` first to mirror what `parachute install runner` writes
658
- // (matches the FIRST_PARTY_FALLBACKS manifest paths order).
659
- writeManifest(h.manifestPath, [
660
- {
661
- name: "parachute-runner",
662
- port: 1945,
663
- paths: ["/runner", "/.parachute"],
664
- health: "/runner/healthz",
665
- version: "0.1.0",
666
- stripPrefix: false,
667
- },
668
- ]);
669
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
670
- const upstream = makeFakeUpstream(() =>
671
- Response.json({ type: "object", properties: { intervalSeconds: { type: "number" } } }),
672
- );
673
- const res = await handleApiModulesConfig(
674
- makeReq("/api/modules/runner/config/schema", {
675
- headers: { authorization: `Bearer ${bearer}` },
676
- }),
677
- { short: "runner" as CuratedModuleShort, suffix: "schema" },
678
- {
679
- db: h.db,
680
- issuer: ISSUER,
681
- manifestPath: h.manifestPath,
682
- upstreamFetch: upstream.fetchFn,
683
- },
684
- );
685
- expect(res.status).toBe(200);
686
- // No /runner prefix — bare /.parachute/config/schema. This is the
687
- // hub#307 fix: pre-fix the URL was http://127.0.0.1:1945/runner/.parachute/config/schema
688
- // and runner returned 404.
689
- expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
690
- });
691
-
692
- test("runner GET /config (no schema) also routes bare", async () => {
693
- writeManifest(h.manifestPath, [
694
- {
695
- name: "parachute-runner",
696
- port: 1945,
697
- paths: ["/runner", "/.parachute"],
698
- health: "/runner/healthz",
699
- version: "0.1.0",
700
- stripPrefix: false,
701
- },
702
- ]);
703
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
704
- const upstream = makeFakeUpstream(() => Response.json({ intervalSeconds: 60 }));
705
- await handleApiModulesConfig(
706
- makeReq("/api/modules/runner/config", {
707
- headers: { authorization: `Bearer ${bearer}` },
708
- }),
709
- { short: "runner" as CuratedModuleShort, suffix: "" },
710
- {
711
- db: h.db,
712
- issuer: ISSUER,
713
- manifestPath: h.manifestPath,
714
- upstreamFetch: upstream.fetchFn,
715
- },
716
- );
717
- expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config");
718
- });
719
-
720
- test("runner PUT /config also routes bare with body", async () => {
721
- writeManifest(h.manifestPath, [
722
- {
723
- name: "parachute-runner",
724
- port: 1945,
725
- paths: ["/runner", "/.parachute"],
726
- health: "/runner/healthz",
727
- version: "0.1.0",
728
- stripPrefix: false,
729
- },
730
- ]);
731
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
732
- const upstream = makeFakeUpstream(() => Response.json({ restart_required: [] }));
733
- await handleApiModulesConfig(
734
- makeReq("/api/modules/runner/config", {
735
- method: "PUT",
736
- headers: {
737
- authorization: `Bearer ${bearer}`,
738
- "content-type": "application/json",
739
- },
740
- body: JSON.stringify({ intervalSeconds: 120 }),
741
- }),
742
- { short: "runner" as CuratedModuleShort, suffix: "" },
743
- {
744
- db: h.db,
745
- issuer: ISSUER,
746
- manifestPath: h.manifestPath,
747
- upstreamFetch: upstream.fetchFn,
748
- },
749
- );
750
- const call = upstream.calls[0];
751
- if (!call) throw new Error("upstream not called");
752
- expect(call.url).toBe("http://127.0.0.1:1945/.parachute/config");
753
- expect(call.method).toBe("PUT");
754
- expect(call.body).toBe(JSON.stringify({ intervalSeconds: 120 }));
755
- });
756
-
757
- test("runner fallback (no services.json entry) — picks up /.parachute from FIRST_PARTY_FALLBACKS paths", async () => {
758
- // bun-link / fresh-install case: the runner row isn't in services.json
759
- // yet but the fallback declares the shape. resolveUpstream returns
760
- // not-installed when neither the row nor the fallback can prove the
761
- // module is up — so this case actually 404s. Pinned as the expected
762
- // shape: hub#307 only changes the upstream-URL math, not the
763
- // installed-detection contract.
764
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
765
- const res = await handleApiModulesConfig(
766
- makeReq("/api/modules/runner/config", {
767
- headers: { authorization: `Bearer ${bearer}` },
768
- }),
769
- { short: "runner" as CuratedModuleShort, suffix: "" },
770
- {
771
- db: h.db,
772
- issuer: ISSUER,
773
- manifestPath: h.manifestPath,
774
- },
775
- );
776
- expect(res.status).toBe(404);
777
- const body = (await res.json()) as { error: string };
778
- expect(body.error).toBe("module_not_installed");
779
- });
780
-
781
- test("vault (stripPrefix:false, no /.parachute in paths) — keeps /vault/<name> prefix (unchanged)", async () => {
782
- // Vault's `.parachute/config` is per-vault, scoped under the
783
- // `/vault/<name>` mount. Routing it bare would lose the vault-name
784
- // context. This test pins that hub#307 doesn't regress vault.
785
- writeManifest(h.manifestPath, [
786
- {
787
- name: "parachute-vault",
788
- port: 1940,
789
- paths: ["/vault/default"],
790
- health: "/vault/default/health",
791
- version: "0.5.0",
792
- },
793
- ]);
794
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
795
- const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
796
- await handleApiModulesConfig(
797
- makeReq("/api/modules/vault/config/schema", {
798
- headers: { authorization: `Bearer ${bearer}` },
799
- }),
800
- { short: "vault", suffix: "schema" },
801
- {
802
- db: h.db,
803
- issuer: ISSUER,
804
- manifestPath: h.manifestPath,
805
- upstreamFetch: upstream.fetchFn,
806
- },
807
- );
808
- // Preserved mount — same as pre-hub#307.
809
- expect(upstream.calls[0]?.url).toBe(
810
- "http://127.0.0.1:1940/vault/default/.parachute/config/schema",
811
- );
812
- });
813
-
814
- test("scribe (stripPrefix:true) — bare URL preserved (unchanged)", async () => {
815
- // Pre-hub#307: stripPrefix:true produced /.parachute/config (via the
816
- // stripPrefix branch). Post-fix: same result via the hostsBareParachute
817
- // branch when /.parachute is in paths, or via the stripPrefix branch
818
- // when it isn't. Scribe ships `paths: ["/scribe"]` (no /.parachute),
819
- // so it takes the stripPrefix branch. Either way, the upstream URL is
820
- // identical to pre-fix behavior.
821
- writeManifest(h.manifestPath, [
822
- {
823
- name: "parachute-scribe",
824
- port: 1943,
825
- paths: ["/scribe"],
826
- health: "/health",
827
- version: "0.4.0",
828
- stripPrefix: true,
829
- },
830
- ]);
831
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
832
- const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
833
- await handleApiModulesConfig(
834
- makeReq("/api/modules/scribe/config/schema", {
835
- headers: { authorization: `Bearer ${bearer}` },
836
- }),
837
- { short: "scribe", suffix: "schema" },
838
- {
839
- db: h.db,
840
- issuer: ISSUER,
841
- manifestPath: h.manifestPath,
842
- upstreamFetch: upstream.fetchFn,
843
- },
844
- );
845
- // Unchanged from pre-hub#307.
846
- expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1943/.parachute/config/schema");
847
- });
848
-
849
- test("mixed: stripPrefix:false module with both /custom and /.parachute → bare for protocol, prefix for others", async () => {
850
- // The hostsBareParachute branch only governs the `/.parachute/config*`
851
- // proxy here. Other proxy code-paths (the generic services-proxy in
852
- // hub-server.ts) handle non-protocol requests; this surface only ever
853
- // forwards to `/.parachute/config[/schema]`, so verifying just that
854
- // route is the right scope.
855
- writeManifest(h.manifestPath, [
856
- {
857
- name: "parachute-runner",
858
- port: 1945,
859
- // Order doesn't matter for hostsBareParachute detection.
860
- paths: ["/.parachute", "/runner"],
861
- health: "/runner/healthz",
862
- version: "0.1.0",
863
- stripPrefix: false,
864
- },
865
- ]);
866
- const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
867
- const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
868
- await handleApiModulesConfig(
869
- makeReq("/api/modules/runner/config/schema", {
870
- headers: { authorization: `Bearer ${bearer}` },
871
- }),
872
- { short: "runner" as CuratedModuleShort, suffix: "schema" },
873
- {
874
- db: h.db,
875
- issuer: ISSUER,
876
- manifestPath: h.manifestPath,
877
- upstreamFetch: upstream.fetchFn,
878
- },
879
- );
880
- expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
881
- });
882
- });