@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.
- package/README.md +7 -4
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +26 -0
- package/src/__tests__/api-account.test.ts +167 -0
- package/src/__tests__/api-modules-config.test.ts +388 -4
- package/src/__tests__/api-modules.test.ts +225 -4
- package/src/__tests__/hub-server.test.ts +221 -41
- package/src/__tests__/hub-settings.test.ts +65 -0
- package/src/__tests__/notes-redirect.test.ts +195 -0
- package/src/__tests__/oauth-handlers.test.ts +1066 -0
- package/src/__tests__/port-assign.test.ts +6 -1
- package/src/__tests__/post-install.test.ts +294 -0
- package/src/__tests__/rate-limit.test.ts +114 -0
- package/src/__tests__/scope-explanations.test.ts +4 -0
- package/src/__tests__/services-manifest.test.ts +231 -0
- package/src/__tests__/setup-wizard.test.ts +29 -12
- package/src/__tests__/setup.test.ts +2 -0
- package/src/__tests__/well-known.test.ts +180 -0
- package/src/admin-clients.ts +8 -0
- package/src/api-account.ts +29 -0
- package/src/api-modules-config.ts +107 -16
- package/src/api-modules-ops.ts +141 -92
- package/src/api-modules.ts +131 -26
- package/src/clients.ts +25 -2
- package/src/commands/install.ts +128 -12
- package/src/commands/lifecycle.ts +47 -3
- package/src/commands/setup.ts +49 -17
- package/src/hub-db.ts +31 -0
- package/src/hub-server.ts +60 -13
- package/src/hub-settings.ts +50 -1
- package/src/install-source.ts +7 -6
- package/src/notes-redirect.ts +121 -0
- package/src/oauth-handlers.ts +237 -20
- package/src/oauth-ui.ts +88 -2
- package/src/post-install.ts +130 -0
- package/src/rate-limit.ts +170 -81
- package/src/scope-explanations.ts +11 -0
- package/src/service-spec.ts +354 -101
- package/src/services-manifest.ts +168 -0
- package/src/setup-wizard.ts +5 -1
- package/src/well-known.ts +94 -1
- package/web/ui/dist/assets/index-D63mUkVX.js +61 -0
- package/web/ui/dist/assets/{index-DRszQoIL.css → index-DliViliP.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- 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,
|
|
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
|
|
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 (
|
|
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
|
@@ -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
|
|
256
|
-
//
|
|
257
|
-
//
|
|
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.
|
|
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
|
+
});
|