@openparachute/hub 0.7.7-rc.8 → 0.7.7
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 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +6 -6
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
package/README.md
CHANGED
|
@@ -342,23 +342,23 @@ Parachute services reserve a block of loopback ports in the canonical range **19
|
|
|
342
342
|
| ---- | ------------------ |
|
|
343
343
|
| 1939 | parachute-hub (internal proxy + static) |
|
|
344
344
|
| 1940 | parachute-vault |
|
|
345
|
-
| 1941 |
|
|
346
|
-
| 1942 |
|
|
345
|
+
| 1941 | *unassigned (released when parachute-agent retired)* |
|
|
346
|
+
| 1942 | parachute-notes *(archived, reservation retained)* |
|
|
347
347
|
| 1943 | parachute-scribe |
|
|
348
|
-
| 1944 |
|
|
349
|
-
| 1945 |
|
|
348
|
+
| 1944 | parachute-app |
|
|
349
|
+
| 1945 | *unassigned (CLI fallback)* |
|
|
350
350
|
| 1946 | parachute-surface *(committed core; UI host — bundles Notes, hosts custom surfaces)* |
|
|
351
351
|
| 1947–1949 | *unassigned (CLI fallback range)* |
|
|
352
352
|
|
|
353
353
|
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.
|
|
354
354
|
|
|
355
|
-
**The CLI is the port authority.** `parachute install <svc>`
|
|
355
|
+
**The CLI is the port authority.** `parachute install <svc>` selects the service port and persists it in `~/.parachute/services.json`. On supervised boot, that manifest entry is authoritative: the hub injects its port into the child environment and ignores a stale per-module `.env` `PORT`. The algorithm:
|
|
356
356
|
|
|
357
357
|
1. Prefer the canonical slot (e.g. vault → 1940).
|
|
358
|
-
2. On collision, walk the unassigned range
|
|
358
|
+
2. On collision, walk the unassigned canonical range starting at 1941.
|
|
359
359
|
3. Range exhausted: assign past 1949 with a warning.
|
|
360
360
|
|
|
361
|
-
|
|
361
|
+
The resulting `services.json` entry is authoritative for supervised starts. Services retain compiled-in defaults for stand-alone execution without a Hub-managed manifest.
|
|
362
362
|
|
|
363
363
|
`parachute expose` probes every service's port at bringup. A service that isn't responding still gets exposed, but you get a `⚠ parachute-<svc> (port …) is not responding` line so proxied requests never silently 502 without explanation.
|
|
364
364
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.7.7
|
|
3
|
+
"version": "0.7.7",
|
|
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": {
|
|
@@ -11,16 +11,8 @@
|
|
|
11
11
|
"bin": {
|
|
12
12
|
"parachute": "src/cli.ts"
|
|
13
13
|
},
|
|
14
|
-
"workspaces": [
|
|
15
|
-
|
|
16
|
-
],
|
|
17
|
-
"files": [
|
|
18
|
-
"src",
|
|
19
|
-
"web/ui/dist",
|
|
20
|
-
"scripts/git-credential-parachute",
|
|
21
|
-
"README.md",
|
|
22
|
-
"LICENSE"
|
|
23
|
-
],
|
|
14
|
+
"workspaces": ["packages/*"],
|
|
15
|
+
"files": ["src", "web/ui/dist", "scripts/git-credential-parachute", "README.md", "LICENSE"],
|
|
24
16
|
"repository": {
|
|
25
17
|
"type": "git",
|
|
26
18
|
"url": "https://github.com/ParachuteComputer/parachute-hub.git"
|
|
@@ -42,7 +34,6 @@
|
|
|
42
34
|
},
|
|
43
35
|
"devDependencies": {
|
|
44
36
|
"@biomejs/biome": "^1.9.4",
|
|
45
|
-
"@openparachute/door-contract": "workspace:*",
|
|
46
37
|
"@types/bun": "latest",
|
|
47
38
|
"@types/qrcode": "^1.5.6"
|
|
48
39
|
},
|
|
@@ -52,6 +43,7 @@
|
|
|
52
43
|
"dependencies": {
|
|
53
44
|
"@node-rs/argon2": "^2.0.2",
|
|
54
45
|
"@openparachute/depcheck": "0.1.1",
|
|
46
|
+
"@openparachute/door-contract": "^0.6.0",
|
|
55
47
|
"jose": "^6.2.2",
|
|
56
48
|
"otpauth": "^9.5.0",
|
|
57
49
|
"qrcode": "^1.5.4"
|
|
@@ -3,7 +3,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ACCOUNT_ERROR_CODES,
|
|
8
|
+
checkAccountDescriptor,
|
|
9
|
+
checkVaultTokenMintResponse,
|
|
10
|
+
validateVaultScopes,
|
|
11
|
+
} from "@openparachute/door-contract";
|
|
7
12
|
import {
|
|
8
13
|
handleAccountCapabilities,
|
|
9
14
|
handleAccountCreateVault,
|
|
@@ -376,6 +381,13 @@ describe("account API — auth gates", () => {
|
|
|
376
381
|
try {
|
|
377
382
|
const res = await handleAccountListVaults(withBearer("/account/vaults", null), deps(h));
|
|
378
383
|
expect(res.status).toBe(401);
|
|
384
|
+
// H1.3 — pin the CURRENT shape: auth-gate failures on /account/* emit
|
|
385
|
+
// {error, error_description} (door-contract account-contract.ts:244-246),
|
|
386
|
+
// via the shared `adminAuthErrorResponse` translator.
|
|
387
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
388
|
+
expect(body.error).toBe("invalid_token");
|
|
389
|
+
expect(typeof body.error_description).toBe("string");
|
|
390
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
379
391
|
} finally {
|
|
380
392
|
h.cleanup();
|
|
381
393
|
}
|
|
@@ -387,6 +399,10 @@ describe("account API — auth gates", () => {
|
|
|
387
399
|
const token = await bearer(h, ["vault:beta:read"]);
|
|
388
400
|
const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
|
|
389
401
|
expect(res.status).toBe(403);
|
|
402
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
403
|
+
expect(body.error).toBe("insufficient_scope");
|
|
404
|
+
expect(typeof body.error_description).toBe("string");
|
|
405
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
390
406
|
} finally {
|
|
391
407
|
h.cleanup();
|
|
392
408
|
}
|
|
@@ -594,6 +610,10 @@ describe("handleAccountMintVaultToken", () => {
|
|
|
594
610
|
};
|
|
595
611
|
expect(body.vault_token.length).toBeGreaterThan(0);
|
|
596
612
|
expect(body.services["vault:field-notes"]?.url).toBe(`${ISSUER}/vault/field-notes`);
|
|
613
|
+
// H1.2 — door-contract conformance against the live
|
|
614
|
+
// `POST /account/vaults/<name>/token` success body (V1.4/C1.4 twin
|
|
615
|
+
// coverage, hub half).
|
|
616
|
+
expect(checkVaultTokenMintResponse(body, "field-notes")).toEqual([]);
|
|
597
617
|
const validated = await validateAccessToken(h.db, body.vault_token, ISSUER);
|
|
598
618
|
expect(validated.payload.aud).toBe("vault.field-notes");
|
|
599
619
|
expect(validated.payload.scope).toBe("vault:field-notes:read vault:field-notes:write");
|
|
@@ -632,7 +652,14 @@ describe("handleAccountMintVaultToken", () => {
|
|
|
632
652
|
deps(h),
|
|
633
653
|
);
|
|
634
654
|
expect(res.status).toBe(404);
|
|
635
|
-
|
|
655
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
656
|
+
expect(body.error).toBe("vault_not_found");
|
|
657
|
+
// H1.3 — pin the CURRENT shape: resource-level failures on /account/*
|
|
658
|
+
// emit {error, message} (door-contract account-contract.ts:244-246) —
|
|
659
|
+
// distinct from the auth-gate's {error, error_description} above.
|
|
660
|
+
expect(typeof body.message).toBe("string");
|
|
661
|
+
expect(body.message.length).toBeGreaterThan(0);
|
|
662
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
636
663
|
} finally {
|
|
637
664
|
h.cleanup();
|
|
638
665
|
}
|
|
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { checkAccountSessionResponse } from "@openparachute/door-contract";
|
|
6
|
+
import { ACCOUNT_ERROR_CODES, checkAccountSessionResponse } from "@openparachute/door-contract";
|
|
7
7
|
import { handleAccountSession } from "../account-session.ts";
|
|
8
8
|
import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken, parseCsrfCookie } from "../csrf.ts";
|
|
9
9
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
@@ -227,6 +227,14 @@ describe("handleAccountSession — method + headers", () => {
|
|
|
227
227
|
const res = handleAccountSession(getReq(undefined, "POST"), { db: harness.db });
|
|
228
228
|
expect(res.status).toBe(405);
|
|
229
229
|
expect(res.headers.get("allow")).toBe("GET");
|
|
230
|
+
// H1.3 — pin the CURRENT shape: resource-level failures on /account/*
|
|
231
|
+
// emit {error, message} (door-contract account-contract.ts:244-246). This
|
|
232
|
+
// is account-session.ts's own `methodNotAllowed`, distinct from
|
|
233
|
+
// account-token.ts's {error, error_description} 405 (see H1.1/H1.3 PR
|
|
234
|
+
// notes — a decisions-needed inconsistency, not fixed here).
|
|
235
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
236
|
+
expect(body).toEqual({ error: "method_not_allowed", message: "use GET" });
|
|
237
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
230
238
|
});
|
|
231
239
|
|
|
232
240
|
test("cache-control: no-store on the 200 path", async () => {
|
|
@@ -19,6 +19,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
19
19
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
+
import { ACCOUNT_ERROR_CODES, checkAccountTokenMintResponse } from "@openparachute/door-contract";
|
|
22
23
|
// The `/account/*` validator (H2 / cloud) hand-rolls its scope check via
|
|
23
24
|
// scope-guard's hasScope — hub never imports scope-guard at runtime (the
|
|
24
25
|
// issuer/validator boundary), but the test asserts the RS-side inheritance
|
|
@@ -118,6 +119,14 @@ describe("handleAccountToken", () => {
|
|
|
118
119
|
const req = new Request(`${ISSUER}/account/token`, { headers: { cookie } });
|
|
119
120
|
const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
|
|
120
121
|
expect(res.status).toBe(405);
|
|
122
|
+
// H1.3 — pin the CURRENT shape: account-token.ts emits {error,
|
|
123
|
+
// error_description} uniformly (its own local `jsonError`), including for
|
|
124
|
+
// this 405 — DIFFERENT from account-session.ts's 405, which emits {error,
|
|
125
|
+
// message} (see that file's test). A decisions-needed inconsistency
|
|
126
|
+
// across /account/*, not fixed here.
|
|
127
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
128
|
+
expect(body).toEqual({ error: "method_not_allowed", error_description: "use POST" });
|
|
129
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
121
130
|
});
|
|
122
131
|
|
|
123
132
|
test("401 when no session cookie is present (unauthenticated → refused)", async () => {
|
|
@@ -126,8 +135,13 @@ describe("handleAccountToken", () => {
|
|
|
126
135
|
const req = postReq("", { [CSRF_FIELD_NAME]: "anything" });
|
|
127
136
|
const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
|
|
128
137
|
expect(res.status).toBe(401);
|
|
129
|
-
const body = (await res.json()) as { error: string };
|
|
138
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
130
139
|
expect(body.error).toBe("unauthenticated");
|
|
140
|
+
// H1.3 — pin the CURRENT shape: auth-gate failures on /account/* emit
|
|
141
|
+
// {error, error_description} (door-contract account-contract.ts:244-246).
|
|
142
|
+
expect(typeof body.error_description).toBe("string");
|
|
143
|
+
expect(body.error_description.length).toBeGreaterThan(0);
|
|
144
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
131
145
|
});
|
|
132
146
|
|
|
133
147
|
test("401 when the cookie names a deleted session", async () => {
|
|
@@ -146,8 +160,11 @@ describe("handleAccountToken", () => {
|
|
|
146
160
|
rotateSigningKey(harness.db);
|
|
147
161
|
const res = await handleAccountToken(postReq(cookie, {}), { db: harness.db, issuer: ISSUER });
|
|
148
162
|
expect(res.status).toBe(403);
|
|
149
|
-
const body = (await res.json()) as { error: string };
|
|
163
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
150
164
|
expect(body.error).toBe("csrf_failed");
|
|
165
|
+
// H1.3 — pin the {error, error_description} shape + ACCOUNT_ERROR_CODES membership.
|
|
166
|
+
expect(typeof body.error_description).toBe("string");
|
|
167
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
151
168
|
});
|
|
152
169
|
|
|
153
170
|
test("403 csrf_failed when the body token doesn't match the cookie token", async () => {
|
|
@@ -176,6 +193,10 @@ describe("handleAccountToken", () => {
|
|
|
176
193
|
const body = (await res.json()) as { error: string; error_description: string };
|
|
177
194
|
expect(body.error).toBe("not_admin");
|
|
178
195
|
expect(body.error_description).toContain("/account/");
|
|
196
|
+
// H1.3 — DECISIONS-NEEDED (cataloged, not fixed here): "not_admin" is a
|
|
197
|
+
// real emitted /account/* error code but is NOT a member of door-contract's
|
|
198
|
+
// ACCOUNT_ERROR_CODES union — a gap in the shared vocabulary. See the PR
|
|
199
|
+
// body's decisions-needed section rather than asserting membership here.
|
|
179
200
|
});
|
|
180
201
|
|
|
181
202
|
test("200 mints the account superset with aud=account, validating against the hub keys", async () => {
|
|
@@ -201,6 +222,9 @@ describe("handleAccountToken", () => {
|
|
|
201
222
|
"parachute:host:auth",
|
|
202
223
|
]);
|
|
203
224
|
expect(body.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
|
|
225
|
+
// H1.2 — door-contract conformance against the live `POST /account/token`
|
|
226
|
+
// success body (V1.4/C1.4 twin coverage, hub half).
|
|
227
|
+
expect(checkAccountTokenMintResponse(body)).toEqual([]);
|
|
204
228
|
|
|
205
229
|
// TTL is roughly the 10-min window.
|
|
206
230
|
const skew = new Date(body.expires_at).getTime() - Date.now();
|
|
@@ -556,6 +556,26 @@ describe("credential connection — renewal (proof of possession)", () => {
|
|
|
556
556
|
expect(rec.provisioned.mintedJtis).toEqual([out.credential.jti!]);
|
|
557
557
|
});
|
|
558
558
|
|
|
559
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
560
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
561
|
+
const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
|
|
562
|
+
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
563
|
+
const cred = await provision(deps, calls);
|
|
564
|
+
|
|
565
|
+
const res = await handleConnections(
|
|
566
|
+
new Request(`http://127.0.0.1/admin/connections/${cred.connection_id}/renew`, {
|
|
567
|
+
method: "POST",
|
|
568
|
+
headers: { authorization: `bearer ${cred.token}` },
|
|
569
|
+
}),
|
|
570
|
+
`/${cred.connection_id}/renew`,
|
|
571
|
+
deps,
|
|
572
|
+
);
|
|
573
|
+
expect(res.status).toBe(200);
|
|
574
|
+
const out = (await res.json()) as { ok: boolean; credential: DeliveredCredential };
|
|
575
|
+
expect(out.ok).toBe(true);
|
|
576
|
+
expect(out.credential.op).toBe("renewed");
|
|
577
|
+
});
|
|
578
|
+
|
|
559
579
|
test("renewed credential can renew again (the chain extends)", async () => {
|
|
560
580
|
const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
|
|
561
581
|
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
@@ -941,6 +961,27 @@ describe("credential connection — claim/reconcile (surface#113)", () => {
|
|
|
941
961
|
]);
|
|
942
962
|
});
|
|
943
963
|
|
|
964
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
965
|
+
test("claim: mixed-case bearer scheme (BeArEr) authenticates identically to canonical Bearer", async () => {
|
|
966
|
+
const { fetchImpl } = mockFetch({});
|
|
967
|
+
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
968
|
+
const direct = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["boulder"] });
|
|
969
|
+
|
|
970
|
+
const claim = await handleConnections(
|
|
971
|
+
new Request(`http://127.0.0.1/admin/connections/${CLAIM_ID}/claim`, {
|
|
972
|
+
method: "POST",
|
|
973
|
+
headers: { "content-type": "application/json", authorization: `BeArEr ${direct.token}` },
|
|
974
|
+
body: JSON.stringify(SURFACE_CLAIM),
|
|
975
|
+
}),
|
|
976
|
+
`/${CLAIM_ID}/claim`,
|
|
977
|
+
deps,
|
|
978
|
+
);
|
|
979
|
+
expect(claim.status).toBe(202);
|
|
980
|
+
const claimBody = (await claim.json()) as { ok: boolean; status: string };
|
|
981
|
+
expect(claimBody.ok).toBe(true);
|
|
982
|
+
expect(claimBody.status).toBe("pending");
|
|
983
|
+
});
|
|
984
|
+
|
|
944
985
|
test("the live docs shape: an UNTAGGED write credential is claimable verbatim (create's write-requires-tags guards NEW grants; the operator's approve sanctions the existing shape)", async () => {
|
|
945
986
|
const { fetchImpl } = mockFetch({});
|
|
946
987
|
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
@@ -21,7 +21,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
21
21
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
import { handleHostAdminToken } from "../admin-host-admin-token.ts";
|
|
26
26
|
import {
|
|
27
27
|
DEFAULT_ADMIN_LOCK_IDLE_SECONDS,
|
|
@@ -239,10 +239,10 @@ describe("recordLoginUnlock", () => {
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
// ---------------------------------------------------------------------------
|
|
242
|
-
// The cascade: a locked session refuses
|
|
242
|
+
// The cascade: a locked session refuses every admin-token mint.
|
|
243
243
|
// ---------------------------------------------------------------------------
|
|
244
244
|
|
|
245
|
-
describe("lock cascade through the
|
|
245
|
+
describe("lock cascade through the admin-token mints", () => {
|
|
246
246
|
test("host-admin-token: 200 when no PIN, 423 when locked, 200 after unlock", async () => {
|
|
247
247
|
const { cookie, sessionId } = await withAdmin();
|
|
248
248
|
rotateSigningKey(harness.db);
|
|
@@ -262,17 +262,6 @@ describe("lock cascade through the four admin-token mints", () => {
|
|
|
262
262
|
expect((await handleHostAdminToken(mk(), { db: harness.db, issuer: ISSUER })).status).toBe(200);
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
test("agent-token cascades (423 when locked)", async () => {
|
|
266
|
-
const { cookie } = await withAdmin();
|
|
267
|
-
rotateSigningKey(harness.db);
|
|
268
|
-
await setPin(harness.db, "4827");
|
|
269
|
-
const res = await handleAgentToken(
|
|
270
|
-
new Request(`${ISSUER}/admin/agent-token`, { headers: { cookie } }),
|
|
271
|
-
{ db: harness.db, issuer: ISSUER },
|
|
272
|
-
);
|
|
273
|
-
expect(res.status).toBe(423);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
265
|
test("vault-admin-token cascades (423 when locked)", async () => {
|
|
277
266
|
const { cookie } = await withAdmin();
|
|
278
267
|
rotateSigningKey(harness.db);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - 401 when the cookie names a deleted session.
|
|
8
8
|
* - 405 on POST.
|
|
9
9
|
* - 200 + JWT carrying `aud: "<short>"` and `<short>:admin` for known modules
|
|
10
|
-
* (scribe / surface
|
|
10
|
+
* (scribe / surface).
|
|
11
11
|
* - 400 for `vault` (per-instance — points at /admin/vault-admin-token/<name>).
|
|
12
12
|
* - 404 for an unknown short.
|
|
13
13
|
* - First-admin gate: 403 for a signed-in non-first-admin (friend).
|
|
@@ -114,7 +114,7 @@ describe("handleModuleToken", () => {
|
|
|
114
114
|
// `<short>:admin` with `aud: <short>`. (runner left this set with its
|
|
115
115
|
// 2026-07-01 registry removal — a LEGACY install still mints via the
|
|
116
116
|
// self-registration gate, pinned below.)
|
|
117
|
-
for (const short of ["scribe", "surface"
|
|
117
|
+
for (const short of ["scribe", "surface"]) {
|
|
118
118
|
test(`200 mints a JWT carrying aud:${short} + ${short}:admin`, async () => {
|
|
119
119
|
const { cookie, userId } = await withSession();
|
|
120
120
|
rotateSigningKey(harness.db);
|
|
@@ -321,7 +321,7 @@ describe("handleModuleToken", () => {
|
|
|
321
321
|
expect(body.error).toBe("use_vault_admin_token");
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
-
test("
|
|
324
|
+
test("retired Agent identifiers do not mint even when stale rows are supplied", async () => {
|
|
325
325
|
const { cookie } = await withSession();
|
|
326
326
|
rotateSigningKey(harness.db);
|
|
327
327
|
const installDir = writeManifestDir("agent");
|
|
@@ -335,40 +335,20 @@ describe("handleModuleToken", () => {
|
|
|
335
335
|
version: "0.1.0",
|
|
336
336
|
installDir,
|
|
337
337
|
},
|
|
338
|
-
];
|
|
339
|
-
const req = new Request(urlFor("agent"), { headers: { cookie } });
|
|
340
|
-
const res = await handleModuleToken(req, "agent", depsWith(services));
|
|
341
|
-
expect(res.status).toBe(200);
|
|
342
|
-
const body = (await res.json()) as { scopes: string[] };
|
|
343
|
-
expect(body.scopes).toEqual(["agent:admin"]);
|
|
344
|
-
} finally {
|
|
345
|
-
rmSync(installDir, { recursive: true, force: true });
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
test("a legacy parachute-channel row still resolves to short `agent` (rename back-compat)", async () => {
|
|
350
|
-
// Un-upgraded operators carry a `parachute-channel` services.json row;
|
|
351
|
-
// LEGACY_MANIFEST_ALIASES maps it to short `agent` so the agent config UI
|
|
352
|
-
// can still mint its admin Bearer until the daemon re-registers.
|
|
353
|
-
const { cookie } = await withSession();
|
|
354
|
-
rotateSigningKey(harness.db);
|
|
355
|
-
const installDir = writeManifestDir("agent");
|
|
356
|
-
try {
|
|
357
|
-
const services: ServiceEntry[] = [
|
|
358
338
|
{
|
|
359
339
|
name: "parachute-channel",
|
|
360
|
-
port:
|
|
361
|
-
paths: ["/
|
|
340
|
+
port: 1942,
|
|
341
|
+
paths: ["/channel"],
|
|
362
342
|
health: "/health",
|
|
363
343
|
version: "0.1.0",
|
|
364
344
|
installDir,
|
|
365
345
|
},
|
|
366
346
|
];
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
347
|
+
for (const short of ["agent", "parachute-agent", "parachute-channel"]) {
|
|
348
|
+
const req = new Request(urlFor(short), { headers: { cookie } });
|
|
349
|
+
const res = await handleModuleToken(req, short, depsWith(services));
|
|
350
|
+
expect(res.status).toBe(404);
|
|
351
|
+
}
|
|
372
352
|
} finally {
|
|
373
353
|
rmSync(installDir, { recursive: true, force: true });
|
|
374
354
|
}
|
|
@@ -151,6 +151,27 @@ describe("routeAdminSurfaces — register + list", () => {
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
155
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
156
|
+
const h = await makeHarness();
|
|
157
|
+
try {
|
|
158
|
+
const token = await mint(h, ["parachute:host:admin"]);
|
|
159
|
+
const res = await routeAdminSurfaces(
|
|
160
|
+
req(
|
|
161
|
+
"POST",
|
|
162
|
+
{ authorization: `bearer ${token}`, "content-type": "application/json" },
|
|
163
|
+
{ name: "brain", mount: "/surface/brain", mode: "prod" },
|
|
164
|
+
),
|
|
165
|
+
deps(h),
|
|
166
|
+
);
|
|
167
|
+
expect(res?.status).toBe(200);
|
|
168
|
+
const body = (await res?.json()) as { ok: boolean };
|
|
169
|
+
expect(body.ok).toBe(true);
|
|
170
|
+
} finally {
|
|
171
|
+
h.cleanup();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
154
175
|
test("POST with a missing name → 400", async () => {
|
|
155
176
|
const h = await makeHarness();
|
|
156
177
|
try {
|
|
@@ -510,6 +510,17 @@ describe("GET /api/hub/upgrade/status", () => {
|
|
|
510
510
|
const res = await handleHubUpgradeStatus(getStatusReq({}), deps);
|
|
511
511
|
expect(res.status).toBe(401);
|
|
512
512
|
});
|
|
513
|
+
|
|
514
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
515
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer (404, not 401)", async () => {
|
|
516
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
517
|
+
const { deps } = baseDeps(harness);
|
|
518
|
+
const res = await handleHubUpgradeStatus(
|
|
519
|
+
getStatusReq({ authorization: `bearer ${bearer}` }),
|
|
520
|
+
deps,
|
|
521
|
+
);
|
|
522
|
+
expect(res.status).toBe(404);
|
|
523
|
+
});
|
|
513
524
|
});
|
|
514
525
|
|
|
515
526
|
describe("detectHubUpgradeMode — §5.3 heuristic", () => {
|
|
@@ -172,6 +172,31 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
172
172
|
}
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
176
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
177
|
+
const h = makeHarness();
|
|
178
|
+
try {
|
|
179
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
180
|
+
try {
|
|
181
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
182
|
+
const resp = await handleApiMintToken(
|
|
183
|
+
jsonRequest(
|
|
184
|
+
{ scope: "scribe:transcribe", expires_in: 3600 },
|
|
185
|
+
{ authorization: `bearer ${op.token}` },
|
|
186
|
+
),
|
|
187
|
+
{ db, issuer: ISSUER },
|
|
188
|
+
);
|
|
189
|
+
expect(resp.status).toBe(200);
|
|
190
|
+
const body = (await resp.json()) as { scope: string };
|
|
191
|
+
expect(body.scope).toBe("scribe:transcribe");
|
|
192
|
+
} finally {
|
|
193
|
+
db.close();
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
h.cleanup();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
175
200
|
// hub#516 parity — the live "mint refused" after `parachute hub set-origin`.
|
|
176
201
|
// An operator/agent credential minted under a PRIOR origin (still a member of
|
|
177
202
|
// the hub's bound-origin set) must keep minting after the canonical issuer
|
|
@@ -208,21 +208,12 @@ describe("parseModulesPath", () => {
|
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
test("accepts
|
|
212
|
-
// Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
|
|
213
|
-
// `isKnownModuleShort` (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old
|
|
214
|
-
// CURATED_MODULES whitelist. agent (renamed from channel 2026-06-17) is in
|
|
215
|
-
// KNOWN_MODULES, so its install path now resolves — fixing the
|
|
216
|
-
// running-but-uninstallable bug.
|
|
217
|
-
expect(parseModulesPath("/api/modules/agent/install")).toEqual({
|
|
218
|
-
short: "agent",
|
|
219
|
-
rest: "install",
|
|
220
|
-
});
|
|
221
|
-
// Other known modules (surface) resolve too.
|
|
211
|
+
test("accepts supported module shorts and rejects retired Agent", () => {
|
|
222
212
|
expect(parseModulesPath("/api/modules/surface/install")).toEqual({
|
|
223
213
|
short: "surface",
|
|
224
214
|
rest: "install",
|
|
225
215
|
});
|
|
216
|
+
expect(parseModulesPath("/api/modules/agent/install")).toBeUndefined();
|
|
226
217
|
});
|
|
227
218
|
|
|
228
219
|
test("rejects unknown shorts (the hub itself + genuinely third-party rows)", () => {
|
|
@@ -309,6 +300,29 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
309
300
|
expect(body.error_description).toContain("parachute:host:admin");
|
|
310
301
|
});
|
|
311
302
|
|
|
303
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
304
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
305
|
+
const { supervisor } = makeIdleSupervisor();
|
|
306
|
+
const { run } = alwaysOkRun();
|
|
307
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
308
|
+
const res = await handleInstall(
|
|
309
|
+
postReq("/api/modules/vault/install", { authorization: `bearer ${bearer}` }),
|
|
310
|
+
"vault",
|
|
311
|
+
{
|
|
312
|
+
db: h.db,
|
|
313
|
+
issuer: ISSUER,
|
|
314
|
+
manifestPath: h.manifestPath,
|
|
315
|
+
configDir: h.dir,
|
|
316
|
+
supervisor,
|
|
317
|
+
run,
|
|
318
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
expect(res.status).toBe(202);
|
|
322
|
+
const body = (await res.json()) as { operation_id: string };
|
|
323
|
+
expect(body.operation_id).toBeDefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
312
326
|
test("202 + operation_id, runs bun add + seeds services.json + spawns", async () => {
|
|
313
327
|
const { supervisor, spawns } = makeIdleSupervisor();
|
|
314
328
|
const { run, calls } = alwaysOkRun();
|
|
@@ -1019,16 +1033,12 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
1019
1033
|
expect(calls).toEqual([]);
|
|
1020
1034
|
});
|
|
1021
1035
|
|
|
1022
|
-
test("
|
|
1023
|
-
// The live signature: the agent (then channel) row carried 19415 instead of
|
|
1024
|
-
// canonical 1941. The API start path (admin SPA / `parachute start agent`)
|
|
1025
|
-
// must apply the SAME reconcile the boot path does — otherwise an
|
|
1026
|
-
// operator-triggered start re-strands the module on the dead port.
|
|
1036
|
+
test("start reconciles a supported module's drifted port back to canonical", async () => {
|
|
1027
1037
|
writeManifest(h.manifestPath, [
|
|
1028
1038
|
{
|
|
1029
|
-
name: "parachute-
|
|
1039
|
+
name: "parachute-scribe",
|
|
1030
1040
|
port: 19415,
|
|
1031
|
-
paths: ["/
|
|
1041
|
+
paths: ["/scribe"],
|
|
1032
1042
|
health: "/health",
|
|
1033
1043
|
version: "0.0.0-linked",
|
|
1034
1044
|
},
|
|
@@ -1038,8 +1048,8 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
1038
1048
|
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1039
1049
|
|
|
1040
1050
|
const res = await handleStart(
|
|
1041
|
-
postReq("/api/modules/
|
|
1042
|
-
"
|
|
1051
|
+
postReq("/api/modules/scribe/start", { authorization: `Bearer ${bearer}` }),
|
|
1052
|
+
"scribe",
|
|
1043
1053
|
{
|
|
1044
1054
|
db: h.db,
|
|
1045
1055
|
issuer: ISSUER,
|
|
@@ -1051,20 +1061,15 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
1051
1061
|
);
|
|
1052
1062
|
|
|
1053
1063
|
expect(res.status).toBe(200);
|
|
1054
|
-
// The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
|
|
1055
|
-
// so it binds + the readiness probe checks the right port.
|
|
1056
1064
|
expect(spawns.length).toBe(1);
|
|
1057
|
-
expect(spawns[0]?.short).toBe("
|
|
1058
|
-
expect(spawns[0]?.env?.PORT).toBe("
|
|
1059
|
-
// services.json row is rewritten to 1941 → the reverse-proxy (which reads
|
|
1060
|
-
// services.json) routes /agent/* to the live port.
|
|
1065
|
+
expect(spawns[0]?.short).toBe("scribe");
|
|
1066
|
+
expect(spawns[0]?.env?.PORT).toBe("1943");
|
|
1061
1067
|
const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
1062
1068
|
services: { name: string; port: number }[];
|
|
1063
1069
|
};
|
|
1064
|
-
expect(onDisk.services.find((s) => s.name === "parachute-
|
|
1065
|
-
// The reconcile event logged on the API path too (deps.log wired — #41 review).
|
|
1070
|
+
expect(onDisk.services.find((s) => s.name === "parachute-scribe")?.port).toBe(1943);
|
|
1066
1071
|
expect(
|
|
1067
|
-
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("
|
|
1072
|
+
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1943")),
|
|
1068
1073
|
).toBe(true);
|
|
1069
1074
|
});
|
|
1070
1075
|
});
|