@openparachute/hub 0.5.12-rc.4 → 0.5.13-rc.12

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 (45) hide show
  1. package/README.md +7 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-clients.test.ts +26 -0
  4. package/src/__tests__/api-account.test.ts +167 -0
  5. package/src/__tests__/api-modules-config.test.ts +388 -4
  6. package/src/__tests__/api-modules.test.ts +225 -4
  7. package/src/__tests__/hub-server.test.ts +221 -41
  8. package/src/__tests__/hub-settings.test.ts +65 -0
  9. package/src/__tests__/notes-redirect.test.ts +195 -0
  10. package/src/__tests__/oauth-handlers.test.ts +1066 -0
  11. package/src/__tests__/port-assign.test.ts +6 -1
  12. package/src/__tests__/post-install.test.ts +294 -0
  13. package/src/__tests__/rate-limit.test.ts +114 -0
  14. package/src/__tests__/scope-explanations.test.ts +4 -0
  15. package/src/__tests__/services-manifest.test.ts +231 -0
  16. package/src/__tests__/setup-wizard.test.ts +29 -12
  17. package/src/__tests__/setup.test.ts +2 -0
  18. package/src/__tests__/well-known.test.ts +180 -0
  19. package/src/admin-clients.ts +8 -0
  20. package/src/api-account.ts +29 -0
  21. package/src/api-modules-config.ts +107 -16
  22. package/src/api-modules-ops.ts +141 -92
  23. package/src/api-modules.ts +131 -26
  24. package/src/clients.ts +25 -2
  25. package/src/commands/install.ts +128 -12
  26. package/src/commands/lifecycle.ts +47 -3
  27. package/src/commands/setup.ts +49 -17
  28. package/src/hub-db.ts +31 -0
  29. package/src/hub-server.ts +60 -13
  30. package/src/hub-settings.ts +50 -1
  31. package/src/install-source.ts +7 -6
  32. package/src/notes-redirect.ts +121 -0
  33. package/src/oauth-handlers.ts +237 -20
  34. package/src/oauth-ui.ts +88 -2
  35. package/src/post-install.ts +130 -0
  36. package/src/rate-limit.ts +170 -81
  37. package/src/scope-explanations.ts +11 -0
  38. package/src/service-spec.ts +354 -101
  39. package/src/services-manifest.ts +168 -0
  40. package/src/setup-wizard.ts +5 -1
  41. package/src/well-known.ts +94 -1
  42. package/web/ui/dist/assets/index-D63mUkVX.js +61 -0
  43. package/web/ui/dist/assets/{index-DRszQoIL.css → index-DliViliP.css} +1 -1
  44. package/web/ui/dist/index.html +2 -2
  45. package/web/ui/dist/assets/index-BJsR-q53.js +0 -61
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `@openparachute/hub` — the local hub for the [Parachute](https://parachute.computer) ecosystem. The `parachute` binary is one of its surfaces.
4
4
 
5
- The hub coordinates the modules running on your machine: it installs them, runs them as background processes, exposes them over Tailscale, serves the discovery document at `/.well-known/parachute.json`, and (soon) issues OAuth tokens. Each module (vault, notes, scribe, channel, …) stays a standalone package; the hub stitches them together.
5
+ The hub coordinates the modules running on your machine: it installs them, runs them as background processes, exposes them over Tailscale, serves the discovery document at `/.well-known/parachute.json`, and (soon) issues OAuth tokens. Each module (vault, app, scribe, …) stays a standalone package; the hub stitches them together.
6
6
 
7
7
  > Previously published as `@openparachute/cli`. Renamed 2026-04-26 to better reflect the role — see [parachute-patterns/hub-as-issuer](https://github.com/ParachuteComputer/parachute-patterns/blob/main/patterns/hub-as-issuer.md). The `parachute` binary name is unchanged.
8
8
 
@@ -160,16 +160,19 @@ Parachute services reserve a block of loopback ports in the canonical range **19
160
160
  | 1939 | parachute-hub (internal proxy + static) |
161
161
  | 1940 | parachute-vault |
162
162
  | 1941 | parachute-channel |
163
- | 1942 | parachute-notes |
163
+ | 1942 | parachute-notes *(deprecating — see [notes#154](https://github.com/ParachuteComputer/parachute-notes/issues/154); folds into parachute-app at 1946)* |
164
164
  | 1943 | parachute-scribe |
165
- | 1944–1949 | *unassigned (CLI fallback range)* |
165
+ | 1944 | *parachute-agent (retired 2026-05-20; slot held — see [`parachute-agent/DEPRECATED.md`](https://github.com/ParachuteComputer/parachute-agent/blob/main/DEPRECATED.md))* |
166
+ | 1945 | parachute-runner *(shipped; exploration-tier, not committed-core)* |
167
+ | 1946 | parachute-app *(committed core; UI host, ships Notes as canonical first app)* |
168
+ | 1947–1949 | *unassigned (CLI fallback range)* |
166
169
 
167
170
  The hub pins 1939 — no fallback. If something else is on 1939 when you run `parachute expose`, the command fails with a pointer to `lsof -iTCP:1939` rather than walking up into another service's slot.
168
171
 
169
172
  **The CLI is the port authority.** `parachute install <svc>` picks the port at install time and writes `PORT=<port>` into `~/.parachute/<svc>/.env`; lifecycle.start merges that .env into the spawn env so the next daemon boot binds the port the CLI assigned. The algorithm:
170
173
 
171
174
  1. Prefer the canonical slot (e.g. vault → 1940).
172
- 2. On collision, walk the unassigned range (1944–1949).
175
+ 2. On collision, walk the unassigned range (1947–1949).
173
176
  3. Range exhausted: assign past 1949 with a warning.
174
177
 
175
178
  Idempotent: an existing `PORT=` in `~/.parachute/<svc>/.env` wins, so re-installs and operator-edited ports survive across upgrades. Services keep their compiled-in fallbacks (vault → 1940 etc.) so a stand-alone `bun run` still works without a CLI-managed .env.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.12-rc.4",
3
+ "version": "0.5.13-rc.12",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -115,6 +115,32 @@ describe("handleGetClient", () => {
115
115
  expect(body.redirect_uris).toEqual(["https://app.example/cb"]);
116
116
  expect(body.scopes).toEqual(["vault:work:read"]);
117
117
  expect(typeof body.registered_at).toBe("string");
118
+ // hub#312 — same_hub surfaced for future SPA badging. Default false
119
+ // when the test registers via the helper (no operator-auth path).
120
+ expect(body.same_hub).toBe(false);
121
+ });
122
+
123
+ test("same_hub=true client surfaces same_hub: true in the response (hub#312)", async () => {
124
+ // The DCR path stamps same_hub=true on operator-authenticated
125
+ // registrations. Pin that the admin view exposes that flag so future
126
+ // SPA changes (per-client same-hub badge) can read it directly from
127
+ // /api/oauth/clients/<id>.
128
+ const { bearer } = await makeOperatorBearer();
129
+ const r = registerClient(harness.db, {
130
+ redirectUris: ["https://app.example/cb"],
131
+ scopes: ["vault:work:read"],
132
+ status: "approved",
133
+ sameHub: true,
134
+ clientName: "SameHubApp",
135
+ });
136
+ const id = r.client.clientId;
137
+ const res = await handleGetClient(getReq(id, bearer), id, {
138
+ db: harness.db,
139
+ issuer: ISSUER,
140
+ });
141
+ expect(res.status).toBe(200);
142
+ const body = (await res.json()) as Record<string, unknown>;
143
+ expect(body.same_hub).toBe(true);
118
144
  });
119
145
 
120
146
  test("returns the row's status after approval (so the SPA can short-circuit re-approve)", async () => {
@@ -29,6 +29,11 @@ import {
29
29
  } from "../api-account.ts";
30
30
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
31
31
  import { hubDbPath, openHubDb } from "../hub-db.ts";
32
+ import {
33
+ CHANGE_PASSWORD_MAX_ATTEMPTS,
34
+ CHANGE_PASSWORD_WINDOW_MS,
35
+ changePasswordRateLimiter,
36
+ } from "../rate-limit.ts";
32
37
  import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
33
38
  import { createUser, getUserById, verifyPassword } from "../users.ts";
34
39
 
@@ -84,6 +89,13 @@ function formBody(values: Record<string, string>): {
84
89
  let harness: Harness;
85
90
  beforeEach(() => {
86
91
  harness = makeHarness();
92
+ // Per-test rate-limit reset — change-password tests share the
93
+ // singleton `changePasswordRateLimiter`, and a test that exhausts a
94
+ // user-id bucket would 429-cascade into the next test if the user-id
95
+ // happened to collide. Per-harness DB → fresh user-ids, so in practice
96
+ // there's no collision, but the explicit reset matches `admin-handlers`
97
+ // discipline and pins the contract.
98
+ changePasswordRateLimiter.reset();
87
99
  });
88
100
  afterEach(() => {
89
101
  harness.cleanup();
@@ -438,6 +450,161 @@ describe("POST /account/change-password", () => {
438
450
  const setCookie = res.headers.get("set-cookie") ?? "";
439
451
  expect(setCookie).not.toContain("Max-Age=0");
440
452
  });
453
+
454
+ // hub#282 — per-user rate-limit on /account/change-password.
455
+ // CHANGE_PASSWORD_MAX_ATTEMPTS attempts per CHANGE_PASSWORD_WINDOW_MS;
456
+ // (CHANGE_PASSWORD_MAX_ATTEMPTS+1)th attempt within the window is 429.
457
+ test("rapid wrong-current_password attempts exhaust the bucket and 429 with Retry-After", async () => {
458
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
459
+ passwordChanged: false,
460
+ });
461
+ const buildReq = () => {
462
+ const { body, headers } = formBody({
463
+ [CSRF_FIELD_NAME]: TEST_CSRF,
464
+ current_password: "this-is-wrong",
465
+ new_password: "long-enough-passphrase",
466
+ new_password_confirm: "long-enough-passphrase",
467
+ });
468
+ return new Request("http://hub.test/account/change-password", {
469
+ method: "POST",
470
+ headers: { ...headers, cookie },
471
+ body,
472
+ });
473
+ };
474
+ // First N attempts: wrong current → 401 each (admitted by rate limiter,
475
+ // failed by argon2id verify).
476
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
477
+ const r = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
478
+ expect(r.status).toBe(401);
479
+ }
480
+ // (N+1)th attempt: rate-limit fires before argon2id → 429 + Retry-After.
481
+ const denied = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
482
+ expect(denied.status).toBe(429);
483
+ const retryAfter = denied.headers.get("retry-after");
484
+ expect(retryAfter).not.toBeNull();
485
+ const seconds = Number(retryAfter);
486
+ expect(seconds).toBeGreaterThan(0);
487
+ // Window is CHANGE_PASSWORD_WINDOW_MS, so retry-after sits in (0, window].
488
+ expect(seconds).toBeLessThanOrEqual(CHANGE_PASSWORD_WINDOW_MS / 1000);
489
+ // Body should re-render the form with the rate-limit message.
490
+ const html = await denied.text();
491
+ expect(html).toContain("Too many password-change attempts");
492
+ });
493
+
494
+ test("rate-limit is per-user: two users have independent buckets", async () => {
495
+ const userA = await sessionCookieFor(harness.db, "user-a", "pw-a", {
496
+ passwordChanged: false,
497
+ });
498
+ const userB = await sessionCookieFor(harness.db, "user-b", "pw-b", {
499
+ passwordChanged: false,
500
+ allowMulti: true,
501
+ });
502
+ const buildReq = (cookie: string) => {
503
+ const { body, headers } = formBody({
504
+ [CSRF_FIELD_NAME]: TEST_CSRF,
505
+ current_password: "wrong",
506
+ new_password: "long-enough-passphrase",
507
+ new_password_confirm: "long-enough-passphrase",
508
+ });
509
+ return new Request("http://hub.test/account/change-password", {
510
+ method: "POST",
511
+ headers: { ...headers, cookie },
512
+ body,
513
+ });
514
+ };
515
+ // Exhaust user-a's bucket.
516
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
517
+ await handleAccountChangePasswordPost(buildReq(userA.cookie), { db: harness.db });
518
+ }
519
+ const aDenied = await handleAccountChangePasswordPost(buildReq(userA.cookie), {
520
+ db: harness.db,
521
+ });
522
+ expect(aDenied.status).toBe(429);
523
+ // user-b's bucket is untouched — first attempt should be admitted
524
+ // (and reject for wrong current → 401, not 429).
525
+ const bAttempt = await handleAccountChangePasswordPost(buildReq(userB.cookie), {
526
+ db: harness.db,
527
+ });
528
+ expect(bAttempt.status).toBe(401);
529
+ });
530
+
531
+ test("rate-limit gate fires before argon2id verify (denied request is fast)", async () => {
532
+ // Pin the "fires before verifyPassword" property with an elapsed-time
533
+ // floor on the 429 response — argon2id verify would push elapsed
534
+ // into the hundreds of ms; the 429 path skips it.
535
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
536
+ passwordChanged: false,
537
+ });
538
+ const buildReq = () => {
539
+ const { body, headers } = formBody({
540
+ [CSRF_FIELD_NAME]: TEST_CSRF,
541
+ current_password: "wrong",
542
+ new_password: "long-enough-passphrase",
543
+ new_password_confirm: "long-enough-passphrase",
544
+ });
545
+ return new Request("http://hub.test/account/change-password", {
546
+ method: "POST",
547
+ headers: { ...headers, cookie },
548
+ body,
549
+ });
550
+ };
551
+ // Fill the bucket.
552
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
553
+ await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
554
+ }
555
+ // The (N+1)th attempt should 429-and-return without touching argon2id.
556
+ const t0 = Date.now();
557
+ const denied = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
558
+ const elapsed = Date.now() - t0;
559
+ expect(denied.status).toBe(429);
560
+ // 200ms is enough headroom even on a noisy runner; an argon2id verify
561
+ // would push elapsed into the hundreds of ms.
562
+ expect(elapsed).toBeLessThan(200);
563
+ });
564
+
565
+ test("CSRF failure does NOT burn a rate-limit slot", async () => {
566
+ // Gate-order invariant: rate-limit fires *after* CSRF, so a junk
567
+ // cross-site POST (which would never have a valid CSRF token) doesn't
568
+ // burn a bucket slot for the victim's session. Pin by sending
569
+ // (max+1) CSRF-broken requests and then confirming a fresh, valid
570
+ // attempt is admitted (would-be 401 for wrong current_password, not
571
+ // 429).
572
+ const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
573
+ passwordChanged: false,
574
+ });
575
+ const csrfBroken = () => {
576
+ const { body, headers } = formBody({
577
+ [CSRF_FIELD_NAME]: "wrong-token",
578
+ current_password: "wrong",
579
+ new_password: "long-enough-passphrase",
580
+ new_password_confirm: "long-enough-passphrase",
581
+ });
582
+ return new Request("http://hub.test/account/change-password", {
583
+ method: "POST",
584
+ headers: { ...headers, cookie },
585
+ body,
586
+ });
587
+ };
588
+ for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS + 2; i++) {
589
+ const r = await handleAccountChangePasswordPost(csrfBroken(), { db: harness.db });
590
+ expect(r.status).toBe(400);
591
+ }
592
+ // Now send a CSRF-valid attempt — should NOT be 429 (CSRF-broken
593
+ // attempts never reached the rate limiter).
594
+ const { body, headers } = formBody({
595
+ [CSRF_FIELD_NAME]: TEST_CSRF,
596
+ current_password: "wrong",
597
+ new_password: "long-enough-passphrase",
598
+ new_password_confirm: "long-enough-passphrase",
599
+ });
600
+ const valid = new Request("http://hub.test/account/change-password", {
601
+ method: "POST",
602
+ headers: { ...headers, cookie },
603
+ body,
604
+ });
605
+ const res = await handleAccountChangePasswordPost(valid, { db: harness.db });
606
+ expect(res.status).toBe(401);
607
+ });
441
608
  });
442
609
 
443
610
  describe("markPasswordChanged", () => {
@@ -248,20 +248,155 @@ describe("handleApiModulesConfig — module-not-installed", () => {
248
248
  });
249
249
  });
250
250
 
251
+ /**
252
+ * Regression suite for hub#310 — vault / scribe / runner retired their
253
+ * FIRST_PARTY_FALLBACKS entries because each module now self-registers its
254
+ * services.json row at boot (vault#356, scribe#50, runner#3). The contract:
255
+ *
256
+ * - **services.json has a row** → operations work using its fields
257
+ * (operator-authoritative).
258
+ * - **services.json has no row** → `module_not_installed` 404. Hub no
259
+ * longer falls back to vendored manifest data — pretending a module is
260
+ * installed when it isn't was the anti-pattern we're retiring.
261
+ *
262
+ * These tests pin both halves of that contract per FALLBACK-retired short
263
+ * (vault / scribe / runner) so a future re-introduction of vendored data
264
+ * would have to explicitly delete them.
265
+ */
266
+ describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
267
+ let h: Harness;
268
+ beforeEach(async () => {
269
+ h = await makeHarness();
270
+ });
271
+ afterEach(() => h.cleanup());
272
+
273
+ test("vault not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
274
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
275
+ const res = await handleApiModulesConfig(
276
+ makeReq("/api/modules/vault/config/schema", {
277
+ headers: { authorization: `Bearer ${bearer}` },
278
+ }),
279
+ { short: "vault", suffix: "schema" },
280
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
281
+ );
282
+ expect(res.status).toBe(404);
283
+ expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
284
+ });
285
+
286
+ test("scribe not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
287
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
288
+ const res = await handleApiModulesConfig(
289
+ makeReq("/api/modules/scribe/config/schema", {
290
+ headers: { authorization: `Bearer ${bearer}` },
291
+ }),
292
+ { short: "scribe", suffix: "schema" },
293
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
294
+ );
295
+ expect(res.status).toBe(404);
296
+ expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
297
+ });
298
+
299
+ test("runner not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
300
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
301
+ const res = await handleApiModulesConfig(
302
+ makeReq("/api/modules/runner/config/schema", {
303
+ headers: { authorization: `Bearer ${bearer}` },
304
+ }),
305
+ { short: "runner", suffix: "schema" },
306
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
307
+ );
308
+ expect(res.status).toBe(404);
309
+ expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
310
+ });
311
+
312
+ test("vault in services.json with self-registered fields → upstream URL composed from entry", async () => {
313
+ // Self-registered vault row (mirrors what vault#356's `selfRegister` writes):
314
+ // installDir + canonical paths + version + stripPrefix omitted (vault doesn't
315
+ // strip). The config proxy must build `/vault/default/.parachute/config/schema`
316
+ // — vault's per-mount routing requires the prefix.
317
+ writeManifest(h.manifestPath, [
318
+ {
319
+ name: "parachute-vault",
320
+ port: 1940,
321
+ paths: ["/vault/default"],
322
+ health: "/vault/default/health",
323
+ version: "0.4.8-rc.4",
324
+ installDir: "/parachute/modules/node_modules/@openparachute/vault",
325
+ },
326
+ ]);
327
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
328
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
329
+ const res = await handleApiModulesConfig(
330
+ makeReq("/api/modules/vault/config/schema", {
331
+ headers: { authorization: `Bearer ${bearer}` },
332
+ }),
333
+ { short: "vault", suffix: "schema" },
334
+ {
335
+ db: h.db,
336
+ issuer: ISSUER,
337
+ manifestPath: h.manifestPath,
338
+ upstreamFetch: upstream.fetchFn,
339
+ },
340
+ );
341
+ expect(res.status).toBe(200);
342
+ expect(upstream.calls[0]?.url).toBe(
343
+ "http://127.0.0.1:1940/vault/default/.parachute/config/schema",
344
+ );
345
+ });
346
+
347
+ test("runner in services.json with self-registered fields → routes to bare /.parachute path", async () => {
348
+ // Self-registered runner row (mirrors what runner#3's `selfRegister` writes):
349
+ // multi-path declaration with `/.parachute` second → hub#307 routes the
350
+ // config proxy to the bare URL regardless of stripPrefix.
351
+ writeManifest(h.manifestPath, [
352
+ {
353
+ name: "parachute-runner",
354
+ port: 1945,
355
+ paths: ["/runner", "/.parachute"],
356
+ health: "/runner/healthz",
357
+ version: "0.1.0-rc.4",
358
+ stripPrefix: false,
359
+ installDir: "/parachute/modules/node_modules/@openparachute/runner",
360
+ },
361
+ ]);
362
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
363
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object" }));
364
+ const res = await handleApiModulesConfig(
365
+ makeReq("/api/modules/runner/config/schema", {
366
+ headers: { authorization: `Bearer ${bearer}` },
367
+ }),
368
+ { short: "runner", suffix: "schema" },
369
+ {
370
+ db: h.db,
371
+ issuer: ISSUER,
372
+ manifestPath: h.manifestPath,
373
+ upstreamFetch: upstream.fetchFn,
374
+ },
375
+ );
376
+ expect(res.status).toBe(200);
377
+ // Bare path — runner hosts /.parachute at root regardless of stripPrefix.
378
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
379
+ });
380
+ });
381
+
251
382
  describe("handleApiModulesConfig — proxy + mint", () => {
252
383
  let h: Harness;
253
384
  beforeEach(async () => {
254
385
  h = await makeHarness();
255
- // Scribe at port 1943 with `/scribe` mount + stripPrefix true (matches
256
- // FIRST_PARTY_FALLBACKS verified upstream paths must be the bare
257
- // `/.parachute/config/schema` shape).
386
+ // Scribe at port 1943 with `/scribe` mount + `stripPrefix: true`.
387
+ // Post hub#310 (vault/scribe/runner FALLBACK retirement), services.json
388
+ // is the authoritative source for `stripPrefix` — scribe#50 self-
389
+ // registers the flag at boot, so the canonical post-self-register row
390
+ // carries it. Verified upstream paths must be the bare
391
+ // `/.parachute/config[/schema]` shape.
258
392
  writeManifest(h.manifestPath, [
259
393
  {
260
394
  name: "parachute-scribe",
261
395
  port: 1943,
262
396
  paths: ["/scribe"],
263
397
  health: "/health",
264
- version: "0.4.0",
398
+ version: "0.4.4-rc.4",
399
+ stripPrefix: true,
265
400
  },
266
401
  ]);
267
402
  });
@@ -490,3 +625,252 @@ describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
490
625
  expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1941/notes/.parachute/config/schema");
491
626
  });
492
627
  });
628
+
629
+ /**
630
+ * hub#307: modules that declare `/.parachute` in their `paths[]` host the
631
+ * universal protocol endpoints at the bare URL — runner is the first
632
+ * example. Before this fix the proxy built `/runner/.parachute/config`
633
+ * (mount-prefixed because stripPrefix is false) and runner returned 404.
634
+ *
635
+ * The fix detects the `/.parachute` declaration in `paths[]` and routes
636
+ * to the bare URL regardless of `stripPrefix`. These tests pin that
637
+ * behavior + verify vault (mount-routed per-vault) keeps its prefixed
638
+ * path so the fix doesn't regress vault config.
639
+ */
640
+ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
641
+ let h: Harness;
642
+ beforeEach(async () => {
643
+ h = await makeHarness();
644
+ });
645
+ afterEach(() => h.cleanup());
646
+
647
+ test("runner (stripPrefix:false + /.parachute in paths) → bare /.parachute/config", async () => {
648
+ // Runner's FIRST_PARTY_FALLBACKS shape: paths includes `/.parachute`
649
+ // explicitly because runner serves the universal protocol at the bare
650
+ // URL. The services.json entry can carry either path first; we put
651
+ // `/runner` first to mirror what `parachute install runner` writes
652
+ // (matches the FIRST_PARTY_FALLBACKS manifest paths order).
653
+ writeManifest(h.manifestPath, [
654
+ {
655
+ name: "parachute-runner",
656
+ port: 1945,
657
+ paths: ["/runner", "/.parachute"],
658
+ health: "/runner/healthz",
659
+ version: "0.1.0",
660
+ stripPrefix: false,
661
+ },
662
+ ]);
663
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
664
+ const upstream = makeFakeUpstream(() =>
665
+ Response.json({ type: "object", properties: { intervalSeconds: { type: "number" } } }),
666
+ );
667
+ const res = await handleApiModulesConfig(
668
+ makeReq("/api/modules/runner/config/schema", {
669
+ headers: { authorization: `Bearer ${bearer}` },
670
+ }),
671
+ { short: "runner", suffix: "schema" },
672
+ {
673
+ db: h.db,
674
+ issuer: ISSUER,
675
+ manifestPath: h.manifestPath,
676
+ upstreamFetch: upstream.fetchFn,
677
+ },
678
+ );
679
+ expect(res.status).toBe(200);
680
+ // No /runner prefix — bare /.parachute/config/schema. This is the
681
+ // hub#307 fix: pre-fix the URL was http://127.0.0.1:1945/runner/.parachute/config/schema
682
+ // and runner returned 404.
683
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
684
+ });
685
+
686
+ test("runner GET /config (no schema) also routes bare", async () => {
687
+ writeManifest(h.manifestPath, [
688
+ {
689
+ name: "parachute-runner",
690
+ port: 1945,
691
+ paths: ["/runner", "/.parachute"],
692
+ health: "/runner/healthz",
693
+ version: "0.1.0",
694
+ stripPrefix: false,
695
+ },
696
+ ]);
697
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
698
+ const upstream = makeFakeUpstream(() => Response.json({ intervalSeconds: 60 }));
699
+ await handleApiModulesConfig(
700
+ makeReq("/api/modules/runner/config", {
701
+ headers: { authorization: `Bearer ${bearer}` },
702
+ }),
703
+ { short: "runner", suffix: "" },
704
+ {
705
+ db: h.db,
706
+ issuer: ISSUER,
707
+ manifestPath: h.manifestPath,
708
+ upstreamFetch: upstream.fetchFn,
709
+ },
710
+ );
711
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config");
712
+ });
713
+
714
+ test("runner PUT /config also routes bare with body", async () => {
715
+ writeManifest(h.manifestPath, [
716
+ {
717
+ name: "parachute-runner",
718
+ port: 1945,
719
+ paths: ["/runner", "/.parachute"],
720
+ health: "/runner/healthz",
721
+ version: "0.1.0",
722
+ stripPrefix: false,
723
+ },
724
+ ]);
725
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
726
+ const upstream = makeFakeUpstream(() => Response.json({ restart_required: [] }));
727
+ await handleApiModulesConfig(
728
+ makeReq("/api/modules/runner/config", {
729
+ method: "PUT",
730
+ headers: {
731
+ authorization: `Bearer ${bearer}`,
732
+ "content-type": "application/json",
733
+ },
734
+ body: JSON.stringify({ intervalSeconds: 120 }),
735
+ }),
736
+ { short: "runner", suffix: "" },
737
+ {
738
+ db: h.db,
739
+ issuer: ISSUER,
740
+ manifestPath: h.manifestPath,
741
+ upstreamFetch: upstream.fetchFn,
742
+ },
743
+ );
744
+ const call = upstream.calls[0];
745
+ if (!call) throw new Error("upstream not called");
746
+ expect(call.url).toBe("http://127.0.0.1:1945/.parachute/config");
747
+ expect(call.method).toBe("PUT");
748
+ expect(call.body).toBe(JSON.stringify({ intervalSeconds: 120 }));
749
+ });
750
+
751
+ test("runner fallback (no services.json entry) — picks up /.parachute from FIRST_PARTY_FALLBACKS paths", async () => {
752
+ // bun-link / fresh-install case: the runner row isn't in services.json
753
+ // yet but the fallback declares the shape. resolveUpstream returns
754
+ // not-installed when neither the row nor the fallback can prove the
755
+ // module is up — so this case actually 404s. Pinned as the expected
756
+ // shape: hub#307 only changes the upstream-URL math, not the
757
+ // installed-detection contract.
758
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
759
+ const res = await handleApiModulesConfig(
760
+ makeReq("/api/modules/runner/config", {
761
+ headers: { authorization: `Bearer ${bearer}` },
762
+ }),
763
+ { short: "runner", suffix: "" },
764
+ {
765
+ db: h.db,
766
+ issuer: ISSUER,
767
+ manifestPath: h.manifestPath,
768
+ },
769
+ );
770
+ expect(res.status).toBe(404);
771
+ const body = (await res.json()) as { error: string };
772
+ expect(body.error).toBe("module_not_installed");
773
+ });
774
+
775
+ test("vault (stripPrefix:false, no /.parachute in paths) — keeps /vault/<name> prefix (unchanged)", async () => {
776
+ // Vault's `.parachute/config` is per-vault, scoped under the
777
+ // `/vault/<name>` mount. Routing it bare would lose the vault-name
778
+ // context. This test pins that hub#307 doesn't regress vault.
779
+ writeManifest(h.manifestPath, [
780
+ {
781
+ name: "parachute-vault",
782
+ port: 1940,
783
+ paths: ["/vault/default"],
784
+ health: "/vault/default/health",
785
+ version: "0.5.0",
786
+ },
787
+ ]);
788
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
789
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
790
+ await handleApiModulesConfig(
791
+ makeReq("/api/modules/vault/config/schema", {
792
+ headers: { authorization: `Bearer ${bearer}` },
793
+ }),
794
+ { short: "vault", suffix: "schema" },
795
+ {
796
+ db: h.db,
797
+ issuer: ISSUER,
798
+ manifestPath: h.manifestPath,
799
+ upstreamFetch: upstream.fetchFn,
800
+ },
801
+ );
802
+ // Preserved mount — same as pre-hub#307.
803
+ expect(upstream.calls[0]?.url).toBe(
804
+ "http://127.0.0.1:1940/vault/default/.parachute/config/schema",
805
+ );
806
+ });
807
+
808
+ test("scribe (stripPrefix:true) — bare URL preserved (unchanged)", async () => {
809
+ // Pre-hub#307: stripPrefix:true produced /.parachute/config (via the
810
+ // stripPrefix branch). Post-fix: same result via the hostsBareParachute
811
+ // branch when /.parachute is in paths, or via the stripPrefix branch
812
+ // when it isn't. Scribe ships `paths: ["/scribe"]` (no /.parachute),
813
+ // so it takes the stripPrefix branch. Either way, the upstream URL is
814
+ // identical to pre-fix behavior.
815
+ writeManifest(h.manifestPath, [
816
+ {
817
+ name: "parachute-scribe",
818
+ port: 1943,
819
+ paths: ["/scribe"],
820
+ health: "/health",
821
+ version: "0.4.0",
822
+ stripPrefix: true,
823
+ },
824
+ ]);
825
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
826
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
827
+ await handleApiModulesConfig(
828
+ makeReq("/api/modules/scribe/config/schema", {
829
+ headers: { authorization: `Bearer ${bearer}` },
830
+ }),
831
+ { short: "scribe", suffix: "schema" },
832
+ {
833
+ db: h.db,
834
+ issuer: ISSUER,
835
+ manifestPath: h.manifestPath,
836
+ upstreamFetch: upstream.fetchFn,
837
+ },
838
+ );
839
+ // Unchanged from pre-hub#307.
840
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1943/.parachute/config/schema");
841
+ });
842
+
843
+ test("mixed: stripPrefix:false module with both /custom and /.parachute → bare for protocol, prefix for others", async () => {
844
+ // The hostsBareParachute branch only governs the `/.parachute/config*`
845
+ // proxy here. Other proxy code-paths (the generic services-proxy in
846
+ // hub-server.ts) handle non-protocol requests; this surface only ever
847
+ // forwards to `/.parachute/config[/schema]`, so verifying just that
848
+ // route is the right scope.
849
+ writeManifest(h.manifestPath, [
850
+ {
851
+ name: "parachute-runner",
852
+ port: 1945,
853
+ // Order doesn't matter for hostsBareParachute detection.
854
+ paths: ["/.parachute", "/runner"],
855
+ health: "/runner/healthz",
856
+ version: "0.1.0",
857
+ stripPrefix: false,
858
+ },
859
+ ]);
860
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
861
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
862
+ await handleApiModulesConfig(
863
+ makeReq("/api/modules/runner/config/schema", {
864
+ headers: { authorization: `Bearer ${bearer}` },
865
+ }),
866
+ { short: "runner", suffix: "schema" },
867
+ {
868
+ db: h.db,
869
+ issuer: ISSUER,
870
+ manifestPath: h.manifestPath,
871
+ upstreamFetch: upstream.fetchFn,
872
+ },
873
+ );
874
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
875
+ });
876
+ });