@openparachute/hub 0.7.6 → 0.7.7-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 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -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 +12 -0
- 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__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-server.test.ts +37 -0
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +279 -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__/scope-explanations.test.ts +22 -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__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- 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/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -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-invites.ts +19 -0
- 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 +2 -1
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/install.ts +86 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +247 -52
- package/src/hub-settings.ts +25 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- 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/scope-explanations.ts +35 -5
- package/src/service-spec.ts +128 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- 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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* H1.1 (contracts-brief H1) — Bearer scheme-casing unification.
|
|
3
|
+
*
|
|
4
|
+
* RFC 7235 §2.1: the auth-scheme token ("Bearer") is case-insensitive; the
|
|
5
|
+
* credential (the token itself) is opaque and must be passed VERBATIM — never
|
|
6
|
+
* case-folded, never trimmed beyond surrounding whitespace. This mirrors
|
|
7
|
+
* parachute-vault's V1.4 (`BEARER_PREFIX`, case-insensitive) and
|
|
8
|
+
* parachute-cloud's C1.3 (`workers/vault/src/auth.ts:130-143`) — the other two
|
|
9
|
+
* door surfaces already converged on this shape; this file (plus the per-site
|
|
10
|
+
* tests below) closes the hub's half.
|
|
11
|
+
*
|
|
12
|
+
* Every converted call site (`src/api-hub-upgrade.ts`, `src/api-tokens.ts`,
|
|
13
|
+
* `src/api-revoke-token.ts`, `src/api-settings-root-redirect.ts`,
|
|
14
|
+
* `src/api-modules.ts` ×2, `src/api-modules-ops.ts`, `src/api-mint-token.ts`,
|
|
15
|
+
* `src/api-settings-hub-origin.ts`, `src/admin-surfaces.ts`,
|
|
16
|
+
* `src/admin-connections.ts` ×2, `src/audience-gate.ts`) uses the SAME
|
|
17
|
+
* two-step shape: a case-insensitive `/^Bearer\s+/i` scheme test, followed by
|
|
18
|
+
* the PRE-EXISTING `header.slice("Bearer ".length).trim()` extraction
|
|
19
|
+
* (unchanged — "Bearer ".length === "bearer ".length === 7, so the fixed-
|
|
20
|
+
* offset slice is casing-agnostic and the token substring is untouched).
|
|
21
|
+
* `src/admin-auth.ts`'s `extractBearerToken` (the documented shared helper,
|
|
22
|
+
* used by `requireScope` + several `/admin/*` and `/api/*` routes already)
|
|
23
|
+
* uses an equivalent `/^Bearer\s+(.+)$/i` regex. This file pins that shared
|
|
24
|
+
* pattern directly, independent of any one handler's DB/deps scaffolding.
|
|
25
|
+
*
|
|
26
|
+
* Live end-to-end coverage (a lowercase/mixed-case Authorization header
|
|
27
|
+
* reaching a real handler and authenticating) lives alongside each site's
|
|
28
|
+
* existing tests, NOT here:
|
|
29
|
+
* - src/__tests__/api-hub-upgrade.test.ts
|
|
30
|
+
* - src/__tests__/api-tokens.test.ts
|
|
31
|
+
* - src/__tests__/api-revoke-token.test.ts
|
|
32
|
+
* - src/__tests__/api-settings-root-redirect.test.ts
|
|
33
|
+
* - src/__tests__/api-modules.test.ts (both handleApiModules + handleApiModulesChannel)
|
|
34
|
+
* - src/__tests__/api-modules-ops.test.ts
|
|
35
|
+
* - src/__tests__/api-mint-token.test.ts
|
|
36
|
+
* - src/__tests__/api-settings-hub-origin.test.ts
|
|
37
|
+
* - src/__tests__/admin-surfaces.test.ts
|
|
38
|
+
* - src/__tests__/admin-connections-credentials.test.ts (renew + claim)
|
|
39
|
+
* - src/__tests__/audience-gate.test.ts
|
|
40
|
+
*/
|
|
41
|
+
import { describe, expect, test } from "bun:test";
|
|
42
|
+
import { extractBearerToken } from "../admin-auth.ts";
|
|
43
|
+
|
|
44
|
+
/** The exact regex + slice shape used at all 12 converted call sites. */
|
|
45
|
+
function siteShapeExtract(header: string | null): string | null {
|
|
46
|
+
if (!header || !/^Bearer\s+/i.test(header)) return null;
|
|
47
|
+
return header.slice("Bearer ".length).trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A JWT-shaped token: base64url alphabet includes uppercase letters, so any
|
|
51
|
+
// real access/operator token already exercises "verbatim, case preserved."
|
|
52
|
+
// Use one explicitly so the pin doesn't depend on a real signer.
|
|
53
|
+
const MIXED_CASE_TOKEN = "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJBQkNkZWYxMjMifQ.SIGNATURE-Xyz";
|
|
54
|
+
|
|
55
|
+
describe("H1.1 — site-shape extraction (regex test + fixed-offset slice)", () => {
|
|
56
|
+
test.each([
|
|
57
|
+
["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
|
|
58
|
+
["bearer", `bearer ${MIXED_CASE_TOKEN}`],
|
|
59
|
+
["BEARER", `BEARER ${MIXED_CASE_TOKEN}`],
|
|
60
|
+
["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
|
|
61
|
+
["bEARER", `bEARER ${MIXED_CASE_TOKEN}`],
|
|
62
|
+
])("scheme %s → token extracted verbatim, case preserved", (_label, header) => {
|
|
63
|
+
expect(siteShapeExtract(header)).toBe(MIXED_CASE_TOKEN);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("wrong scheme keyword (not Bearer) → rejected regardless of case", () => {
|
|
67
|
+
expect(siteShapeExtract(`Basic ${MIXED_CASE_TOKEN}`)).toBeNull();
|
|
68
|
+
expect(siteShapeExtract(`Digest ${MIXED_CASE_TOKEN}`)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("missing header → rejected", () => {
|
|
72
|
+
expect(siteShapeExtract(null)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("scheme with no token → empty string (site's own 'empty bearer token' check catches this)", () => {
|
|
76
|
+
expect(siteShapeExtract("Bearer ")).toBe("");
|
|
77
|
+
expect(siteShapeExtract("bearer ")).toBe("");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("extra internal whitespace after the scheme is tolerated (trim absorbs it), casing still ignored", () => {
|
|
81
|
+
expect(siteShapeExtract(`bearer ${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
|
|
82
|
+
expect(siteShapeExtract(`Bearer\t${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("H1.1 — admin-auth.ts extractBearerToken (the documented shared helper)", () => {
|
|
87
|
+
function reqWith(header: string | undefined): Request {
|
|
88
|
+
return new Request("http://127.0.0.1/admin/probe", {
|
|
89
|
+
headers: header !== undefined ? { authorization: header } : {},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test.each([
|
|
94
|
+
["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
|
|
95
|
+
["bearer", `bearer ${MIXED_CASE_TOKEN}`],
|
|
96
|
+
["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
|
|
97
|
+
])("scheme %s → extractBearerToken returns the token verbatim", (_label, header) => {
|
|
98
|
+
expect(extractBearerToken(reqWith(header))).toBe(MIXED_CASE_TOKEN);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("missing Authorization header → AdminAuthError(401)", () => {
|
|
102
|
+
expect(() => extractBearerToken(reqWith(undefined))).toThrow(/missing Authorization header/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("non-Bearer scheme → AdminAuthError(401)", () => {
|
|
106
|
+
expect(() => extractBearerToken(reqWith(`Basic ${MIXED_CASE_TOKEN}`))).toThrow(
|
|
107
|
+
/Authorization header must be 'Bearer <token>'/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -99,6 +99,20 @@ describe("shouldInjectChrome", () => {
|
|
|
99
99
|
expect(shouldInjectChrome("/surface/notes-archive/")).toBe(true);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
test("default: opts out the app's surface mount at /surface/parachute/* (W2-12)", () => {
|
|
103
|
+
expect(shouldInjectChrome("/surface/parachute")).toBe(false);
|
|
104
|
+
expect(shouldInjectChrome("/surface/parachute/")).toBe(false);
|
|
105
|
+
expect(shouldInjectChrome("/surface/parachute/index.html")).toBe(false);
|
|
106
|
+
expect(shouldInjectChrome("/surface/parachute/assets/index-XXX.js")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("the /surface/parachute/ opt-out does not over-match sibling paths", () => {
|
|
110
|
+
// `/surface/parachutes` must NOT match `/surface/parachute/` — the slash
|
|
111
|
+
// boundary check applies to the new entry the same as the legacy one.
|
|
112
|
+
expect(shouldInjectChrome("/surface/parachutes")).toBe(true);
|
|
113
|
+
expect(shouldInjectChrome("/surface/parachute-club/")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
102
116
|
test("custom opt-out list is honored", () => {
|
|
103
117
|
expect(shouldInjectChrome("/foo/bar", ["/foo/"])).toBe(false);
|
|
104
118
|
expect(shouldInjectChrome("/baz", ["/foo/"])).toBe(true);
|
|
@@ -111,8 +125,11 @@ describe("shouldInjectChrome", () => {
|
|
|
111
125
|
expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
|
|
112
126
|
});
|
|
113
127
|
|
|
114
|
-
test("the canonical opt-out list contains /surface/notes/", () => {
|
|
128
|
+
test("the canonical opt-out list contains BOTH /surface/notes/ and /surface/parachute/", () => {
|
|
129
|
+
// W2-12 is additive: the legacy notes-ui mount keeps its opt-out for
|
|
130
|
+
// existing installs; the renamed app-surface mount gets its own.
|
|
115
131
|
expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/notes/");
|
|
132
|
+
expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/parachute/");
|
|
116
133
|
});
|
|
117
134
|
});
|
|
118
135
|
|
|
@@ -502,7 +502,7 @@ describe("doctor — canonical-port-drift detection (read-only)", () => {
|
|
|
502
502
|
test("a non-canonical port + a duplicate-port pair → port-drift WARNs naming the services", async () => {
|
|
503
503
|
const h = makeHarness();
|
|
504
504
|
try {
|
|
505
|
-
//
|
|
505
|
+
// Scribe drifted off 1943 onto Surface's canonical 1946 (a collision).
|
|
506
506
|
writeManifestRows(h.manifestPath, [
|
|
507
507
|
{
|
|
508
508
|
name: "parachute-vault",
|
|
@@ -511,21 +511,17 @@ describe("doctor — canonical-port-drift detection (read-only)", () => {
|
|
|
511
511
|
health: "/h",
|
|
512
512
|
version: "1",
|
|
513
513
|
},
|
|
514
|
-
{ name: "parachute-scribe", port:
|
|
515
|
-
{ name: "parachute-
|
|
514
|
+
{ name: "parachute-scribe", port: 1946, paths: ["/scribe"], health: "/h", version: "1" },
|
|
515
|
+
{ name: "parachute-surface", port: 1946, paths: ["/surface"], health: "/h", version: "1" },
|
|
516
516
|
]);
|
|
517
517
|
seedOperatorToken(h.configDir);
|
|
518
518
|
const { code, checks } = await runDoctor(h, healthyDeps());
|
|
519
519
|
const pd = byName(checks, "port-drift");
|
|
520
520
|
expect(pd?.status).toBe("warn");
|
|
521
|
-
// Names the drifted service AND the colliding pair.
|
|
522
521
|
expect(pd?.detail).toContain("scribe");
|
|
523
|
-
expect(pd?.detail).toContain("
|
|
524
|
-
expect(pd?.detail).toContain("parachute-scribe + parachute-
|
|
522
|
+
expect(pd?.detail).toContain("1946");
|
|
523
|
+
expect(pd?.detail).toContain("parachute-scribe + parachute-surface");
|
|
525
524
|
expect(pd?.fix).toBe("parachute doctor --fix");
|
|
526
|
-
// Drift is advisory — exit stays 0 (a WARN, not a FAIL). The duplicate
|
|
527
|
-
// rows also trip modules-alive (both can't bind 1944) but that's expected
|
|
528
|
-
// for this fixture; we only assert on port-drift here.
|
|
529
525
|
expect([0, 1]).toContain(code);
|
|
530
526
|
} finally {
|
|
531
527
|
h.cleanup();
|
|
@@ -732,22 +728,19 @@ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non
|
|
|
732
728
|
test("--fix reports a duplicate-port collision but does not auto-resolve it", async () => {
|
|
733
729
|
const h = makeHarness();
|
|
734
730
|
try {
|
|
735
|
-
// Two services collide on
|
|
736
|
-
// diff fixes the canonical drift; the collision is reported, not guessed.
|
|
731
|
+
// Two supported services collide on unassigned 1945; both are off canonical.
|
|
737
732
|
writeManifestRows(h.manifestPath, [
|
|
738
|
-
{ name: "parachute-scribe", port:
|
|
739
|
-
{ name: "parachute-
|
|
733
|
+
{ name: "parachute-scribe", port: 1945, paths: ["/scribe"], health: "/h", version: "1" },
|
|
734
|
+
{ name: "parachute-surface", port: 1945, paths: ["/surface"], health: "/h", version: "1" },
|
|
740
735
|
]);
|
|
741
736
|
const { code, lines } = await runFix(h, {}, { yes: true });
|
|
742
737
|
const text = lines.join("\n");
|
|
743
738
|
expect(text.toLowerCase()).toContain("shared by");
|
|
744
|
-
expect(text).toContain("parachute-scribe + parachute-
|
|
745
|
-
// scribe → 1943 and agent → 1941 are both off 1944, so after the rewrite
|
|
746
|
-
// they no longer collide; fix applied, exit 0.
|
|
739
|
+
expect(text).toContain("parachute-scribe + parachute-surface");
|
|
747
740
|
expect(code).toBe(0);
|
|
748
741
|
const rows = readRows(h.manifestPath);
|
|
749
742
|
expect(rows.find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
|
|
750
|
-
expect(rows.find((r) => r.name === "parachute-
|
|
743
|
+
expect(rows.find((r) => r.name === "parachute-surface")?.port).toBe(1946);
|
|
751
744
|
} finally {
|
|
752
745
|
h.cleanup();
|
|
753
746
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ACCESS_TOKEN_TTL_SECONDS as CONTRACT_ACCESS_TTL,
|
|
4
|
+
ACCOUNT_SELF_ADMIN_SCOPE as CONTRACT_ACCOUNT_ADMIN,
|
|
5
|
+
ACCOUNT_SELF_READ_SCOPE as CONTRACT_ACCOUNT_READ,
|
|
6
|
+
REFRESH_GRACE_MS as CONTRACT_REFRESH_GRACE,
|
|
7
|
+
REFRESH_TOKEN_TTL_MS as CONTRACT_REFRESH_TTL,
|
|
8
|
+
hasAccountScope,
|
|
9
|
+
} from "@openparachute/door-contract";
|
|
10
|
+
import { ACCESS_TOKEN_TTL_SECONDS, REFRESH_GRACE_MS, REFRESH_TOKEN_TTL_MS } from "../jwt-sign.ts";
|
|
11
|
+
import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "../scope-explanations.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Drift detector for the shared door contract (Cloud+Hub shared-core campaign,
|
|
15
|
+
* parachute-cloud#116, Phase A). The hub's issuer constants + account-scope
|
|
16
|
+
* strings are duplicated in `@openparachute/door-contract` (the hosted cloud
|
|
17
|
+
* door duplicates them too). These assertions bind the hub's LIVE runtime values
|
|
18
|
+
* to the shared canon — no runtime code path is changed by this test, but any
|
|
19
|
+
* future divergence between the hub's issuer and the shared contract fails here,
|
|
20
|
+
* forcing the change through the shared package (and thus the cloud twin).
|
|
21
|
+
*
|
|
22
|
+
* When the hub adopts the contract at runtime (Phase B — `jwt-sign.ts` imports
|
|
23
|
+
* these constants instead of re-declaring them), these become identity checks;
|
|
24
|
+
* until then they are the guardrail.
|
|
25
|
+
*/
|
|
26
|
+
describe("door-contract parity — token constants", () => {
|
|
27
|
+
test("the hub's issuer TTL/grace equal the shared contract", () => {
|
|
28
|
+
expect(ACCESS_TOKEN_TTL_SECONDS).toBe(CONTRACT_ACCESS_TTL);
|
|
29
|
+
expect(REFRESH_TOKEN_TTL_MS).toBe(CONTRACT_REFRESH_TTL);
|
|
30
|
+
expect(REFRESH_GRACE_MS).toBe(CONTRACT_REFRESH_GRACE);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("door-contract parity — account scopes", () => {
|
|
35
|
+
test("the hub's account scope strings equal the shared contract", () => {
|
|
36
|
+
expect(ACCOUNT_SELF_ADMIN_SCOPE).toBe(CONTRACT_ACCOUNT_ADMIN);
|
|
37
|
+
expect(ACCOUNT_SELF_READ_SCOPE).toBe(CONTRACT_ACCOUNT_READ);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the shared checker agrees with the hub's admin⊇read intent", () => {
|
|
41
|
+
// The hub's `/account/*` gate treats `account:self:admin` as satisfying a
|
|
42
|
+
// read requirement (scope-explanations.ts). The shared checker must too.
|
|
43
|
+
expect(hasAccountScope([ACCOUNT_SELF_ADMIN_SCOPE], "self", "read")).toBe(true);
|
|
44
|
+
expect(hasAccountScope([ACCOUNT_SELF_READ_SCOPE], "self", "admin")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -993,6 +993,29 @@ describe("hubFetch routing", () => {
|
|
|
993
993
|
}
|
|
994
994
|
});
|
|
995
995
|
|
|
996
|
+
test("retired Agent token endpoints neither mint nor redirect", async () => {
|
|
997
|
+
const h = makeHarness();
|
|
998
|
+
try {
|
|
999
|
+
const dist = join(h.dir, "dist");
|
|
1000
|
+
mkdirIfMissing(dist);
|
|
1001
|
+
writeFileSync(join(dist, "index.html"), "<!doctype html><title>admin shell</title>");
|
|
1002
|
+
const fetcher = hubFetch(h.dir, { spaDistDir: dist });
|
|
1003
|
+
|
|
1004
|
+
for (const path of ["/admin/agent-token", "/admin/channel-token"]) {
|
|
1005
|
+
const post = await fetcher(req(path, { method: "POST" }));
|
|
1006
|
+
expect(post.status).toBe(405);
|
|
1007
|
+
expect(post.headers.get("location")).toBeNull();
|
|
1008
|
+
|
|
1009
|
+
const get = await fetcher(req(path));
|
|
1010
|
+
expect(get.status).toBe(200);
|
|
1011
|
+
expect(get.headers.get("location")).toBeNull();
|
|
1012
|
+
expect(await get.text()).toContain("admin shell");
|
|
1013
|
+
}
|
|
1014
|
+
} finally {
|
|
1015
|
+
h.cleanup();
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
996
1019
|
// 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
|
|
997
1020
|
// 301-redirect to the new /admin/* mount. Tests cover every entry in the
|
|
998
1021
|
// dispatch — operator bookmarks landing on any of these still work.
|
|
@@ -1249,6 +1272,20 @@ describe("hubFetch routing", () => {
|
|
|
1249
1272
|
}
|
|
1250
1273
|
});
|
|
1251
1274
|
|
|
1275
|
+
test("retired /channel/* paths no longer redirect to /agent/*", async () => {
|
|
1276
|
+
const h = makeHarness();
|
|
1277
|
+
try {
|
|
1278
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
1279
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
1280
|
+
req("/channel/mcp/default?q=1"),
|
|
1281
|
+
);
|
|
1282
|
+
expect(res.status).toBe(404);
|
|
1283
|
+
expect(res.headers.get("location")).toBeNull();
|
|
1284
|
+
} finally {
|
|
1285
|
+
h.cleanup();
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1252
1289
|
test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
|
|
1253
1290
|
const h = makeHarness();
|
|
1254
1291
|
try {
|
|
@@ -239,6 +239,35 @@ describe("renderHub — signed-out slimming (operator feedback)", () => {
|
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
describe("renderHub — Get started tiles are uis[]-driven (W2-12)", () => {
|
|
243
|
+
// The tile logic runs client-side inside the discovery IIFE; these pins
|
|
244
|
+
// assert the emitted script's SHAPE (the JS-parses check above guards its
|
|
245
|
+
// validity). The section is uis[]-driven — one tile per active uis[]
|
|
246
|
+
// sub-unit, whichever mount identity (/surface/notes or
|
|
247
|
+
// /surface/parachute) an install serves — with the pre-W2-12 hardcoded
|
|
248
|
+
// "Open Notes" tile demoted to a fallback for surface-host rows that
|
|
249
|
+
// predate uis{} self-registration.
|
|
250
|
+
const html = renderHub({
|
|
251
|
+
session: { displayName: "operator", csrfToken: "csrf-w212" },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("the discovery script iterates uis[] sub-units", () => {
|
|
255
|
+
expect(html).toContain("svc.uis");
|
|
256
|
+
// Inactive sub-units are skipped; absent status counts as active.
|
|
257
|
+
expect(html).toContain("ui.status");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("the legacy 'Open Notes' hardcode survives only as the no-uis fallback", () => {
|
|
261
|
+
// Still present (pre-uis surface-host installs keep their CTA) …
|
|
262
|
+
expect(html).toContain("Open Notes");
|
|
263
|
+
expect(html).toContain("'/surface/notes/'");
|
|
264
|
+
// … but no longer keyed on the bare presence of parachute-surface as
|
|
265
|
+
// the PRIMARY path: the uis[] loop renders first, the hasSurface check
|
|
266
|
+
// only backstops an empty tile list.
|
|
267
|
+
expect(html).toContain("Legacy fallback");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
242
271
|
describe("renderHub — signed-in indicator (rc.13)", () => {
|
|
243
272
|
test("session user → 'Signed in as <name>' + inline POST form with CSRF", () => {
|
|
244
273
|
const html = renderHub({
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
|
|
6
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
+
import {
|
|
8
|
+
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
9
|
+
getRootRedirect,
|
|
10
|
+
setRootRedirect,
|
|
11
|
+
} from "../hub-settings.ts";
|
|
6
12
|
import { findService, upsertService } from "../services-manifest.ts";
|
|
7
13
|
|
|
8
14
|
function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
|
|
@@ -62,6 +68,71 @@ describe("install", () => {
|
|
|
62
68
|
}
|
|
63
69
|
});
|
|
64
70
|
|
|
71
|
+
test("refuses `install agent` without invoking bun or the deprecated binary", async () => {
|
|
72
|
+
const { path, cleanup } = makeTempPath();
|
|
73
|
+
try {
|
|
74
|
+
const calls: string[][] = [];
|
|
75
|
+
const logs: string[] = [];
|
|
76
|
+
const code = await install("agent", {
|
|
77
|
+
runner: async (cmd) => {
|
|
78
|
+
calls.push([...cmd]);
|
|
79
|
+
return 0;
|
|
80
|
+
},
|
|
81
|
+
manifestPath: path,
|
|
82
|
+
startService: async () => 0,
|
|
83
|
+
isLinked: () => false,
|
|
84
|
+
portProbe: async () => false,
|
|
85
|
+
log: (line) => logs.push(line),
|
|
86
|
+
});
|
|
87
|
+
expect(code).toBe(1);
|
|
88
|
+
expect(calls).toEqual([]);
|
|
89
|
+
expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
|
|
90
|
+
expect(logs.join("\n")).toMatch(/Vault and Surface/);
|
|
91
|
+
} finally {
|
|
92
|
+
cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("refuses explicit retired Agent npm package names before invoking bun", async () => {
|
|
97
|
+
for (const packageName of [
|
|
98
|
+
"@openparachute/agent",
|
|
99
|
+
"@openparachute/agent@0.9.0",
|
|
100
|
+
"@openparachute/channel",
|
|
101
|
+
]) {
|
|
102
|
+
const calls: string[][] = [];
|
|
103
|
+
const logs: string[] = [];
|
|
104
|
+
const code = await install(packageName, {
|
|
105
|
+
runner: async (cmd) => {
|
|
106
|
+
calls.push([...cmd]);
|
|
107
|
+
return 0;
|
|
108
|
+
},
|
|
109
|
+
log: (line) => logs.push(line),
|
|
110
|
+
});
|
|
111
|
+
expect(code).toBe(1);
|
|
112
|
+
expect(calls).toEqual([]);
|
|
113
|
+
expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("refuses a local checkout whose package metadata is a retired Agent package", async () => {
|
|
118
|
+
const pkgDir = mkdtempSync(join(tmpdir(), "pcli-retired-agent-"));
|
|
119
|
+
try {
|
|
120
|
+
const calls: string[][] = [];
|
|
121
|
+
const code = await install(pkgDir, {
|
|
122
|
+
runner: async (cmd) => {
|
|
123
|
+
calls.push([...cmd]);
|
|
124
|
+
return 0;
|
|
125
|
+
},
|
|
126
|
+
readPackageName: () => "@openparachute/agent",
|
|
127
|
+
log: () => {},
|
|
128
|
+
});
|
|
129
|
+
expect(code).toBe(1);
|
|
130
|
+
expect(calls).toEqual([]);
|
|
131
|
+
} finally {
|
|
132
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
65
136
|
test("runs bun add -g then init; seeds manifest when service didn't write one", async () => {
|
|
66
137
|
const { path, cleanup } = makeTempPath();
|
|
67
138
|
try {
|
|
@@ -408,7 +479,7 @@ describe("install", () => {
|
|
|
408
479
|
test("ADOPT-KILLS an attributable same-module orphan on the canonical port + reclaims it (#609)", async () => {
|
|
409
480
|
// Wipe-recovery: `rm -rf ~/.parachute` + re-`init` leaves the supervised
|
|
410
481
|
// vault child running on :1940. The fresh install must reclaim the canonical
|
|
411
|
-
// port (adopt-kill the attributable orphan) rather than port-walk to
|
|
482
|
+
// port (adopt-kill the attributable orphan) rather than port-walk to 1945.
|
|
412
483
|
const { path, configDir, cleanup } = makeTempPath();
|
|
413
484
|
try {
|
|
414
485
|
const logs: string[] = [];
|
|
@@ -1636,9 +1707,10 @@ describe("install", () => {
|
|
|
1636
1707
|
log: (l) => logs.push(l),
|
|
1637
1708
|
});
|
|
1638
1709
|
expect(code).toBe(0);
|
|
1639
|
-
//
|
|
1710
|
+
// Agent retirement released 1941, so it is now the first unassigned
|
|
1711
|
+
// canonical slot available to the fallback walker.
|
|
1640
1712
|
const entry = findService("parachute-vault", path);
|
|
1641
|
-
expect(entry?.port).toBe(
|
|
1713
|
+
expect(entry?.port).toBe(1941);
|
|
1642
1714
|
expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
|
|
1643
1715
|
// .env is not touched.
|
|
1644
1716
|
const envPath = join(configDir, "vault", ".env");
|
|
@@ -1865,6 +1937,33 @@ describe("install", () => {
|
|
|
1865
1937
|
}
|
|
1866
1938
|
});
|
|
1867
1939
|
|
|
1940
|
+
test("refuses third-party metadata that declares a retired Agent manifest identity", async () => {
|
|
1941
|
+
const { path, cleanup } = makeTempPath();
|
|
1942
|
+
try {
|
|
1943
|
+
const logs: string[] = [];
|
|
1944
|
+
const code = await install("@vendor/agent-wrapper", {
|
|
1945
|
+
runner: async () => 0,
|
|
1946
|
+
manifestPath: path,
|
|
1947
|
+
isLinked: () => false,
|
|
1948
|
+
log: (line) => logs.push(line),
|
|
1949
|
+
readManifest: async () => ({
|
|
1950
|
+
name: "claw",
|
|
1951
|
+
manifestName: "@vendor/agent-wrapper",
|
|
1952
|
+
port: 1952,
|
|
1953
|
+
paths: ["/claw"],
|
|
1954
|
+
health: "/claw/health",
|
|
1955
|
+
}),
|
|
1956
|
+
findGlobalInstall: () =>
|
|
1957
|
+
"/fake/bun-globals/node_modules/@vendor/agent-wrapper/package.json",
|
|
1958
|
+
});
|
|
1959
|
+
expect(code).toBe(1);
|
|
1960
|
+
expect(findService("claw", path)).toBeUndefined();
|
|
1961
|
+
expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
|
|
1962
|
+
} finally {
|
|
1963
|
+
cleanup();
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1868
1967
|
test("npm-installed third-party module persists installDir from bun globals", async () => {
|
|
1869
1968
|
// hub#83: for `parachute install <npm-pkg>`, installDir is dirname of
|
|
1870
1969
|
// the package.json that findGlobalInstall returns. Without this,
|
|
@@ -1940,7 +2039,8 @@ describe("install", () => {
|
|
|
1940
2039
|
expect(startCalls).toEqual(["someapp"]);
|
|
1941
2040
|
// Log lines speak in the canonical short name too. Port comes from
|
|
1942
2041
|
// assignServicePort (third-party gets the first unassigned canonical
|
|
1943
|
-
// slot, currently 1944
|
|
2042
|
+
// slot, currently 1945 — 1944 is parachute-app's canonical assigned
|
|
2043
|
+
// slot as of hub-parity P5), not the manifest's port hint.
|
|
1944
2044
|
const joined = logs.join("\n");
|
|
1945
2045
|
expect(joined).toMatch(/Seeded services\.json entry for someapp/);
|
|
1946
2046
|
expect(joined).toMatch(/someapp registered on port \d+/);
|
|
@@ -2316,3 +2416,177 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
|
|
|
2316
2416
|
expect(opts.log).toBe(log);
|
|
2317
2417
|
});
|
|
2318
2418
|
});
|
|
2419
|
+
|
|
2420
|
+
// hub-parity P5 (2026-07-11): `parachute install app` defaults the hub's
|
|
2421
|
+
// bare `/` redirect to the app's front door — SET-IF-UNSET ONLY. Every test
|
|
2422
|
+
// here injects `rootRedirectDb` (a hub.db opened in the same disposable
|
|
2423
|
+
// tempdir `makeTempPath()` already isolates) so no real
|
|
2424
|
+
// `~/.parachute/hub.db` is ever touched — never drive this against the
|
|
2425
|
+
// operator's live install.
|
|
2426
|
+
describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
2427
|
+
// The write gates on `resolveRootRedirectDetailed(db).source === "default"`,
|
|
2428
|
+
// which consults `PARACHUTE_HUB_ROOT_REDIRECT`. Neutralize any ambient value
|
|
2429
|
+
// (Aaron's box, CI) so the default-tier tests are deterministic; the
|
|
2430
|
+
// env-set test below manages its own value inside its own body.
|
|
2431
|
+
let savedEnv: string | undefined;
|
|
2432
|
+
beforeEach(() => {
|
|
2433
|
+
savedEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2434
|
+
delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2435
|
+
});
|
|
2436
|
+
afterEach(() => {
|
|
2437
|
+
if (savedEnv === undefined) {
|
|
2438
|
+
delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2439
|
+
} else {
|
|
2440
|
+
process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = savedEnv;
|
|
2441
|
+
}
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
test("fresh install (no prior root_redirect) sets it to /app/", async () => {
|
|
2445
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2446
|
+
try {
|
|
2447
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2448
|
+
const logs: string[] = [];
|
|
2449
|
+
try {
|
|
2450
|
+
const code = await install("app", {
|
|
2451
|
+
runner: async () => 0,
|
|
2452
|
+
manifestPath: path,
|
|
2453
|
+
configDir,
|
|
2454
|
+
startService: async () => 0,
|
|
2455
|
+
isLinked: () => false,
|
|
2456
|
+
portProbe: async () => false,
|
|
2457
|
+
rootRedirectDb: db,
|
|
2458
|
+
log: (l) => logs.push(l),
|
|
2459
|
+
});
|
|
2460
|
+
expect(code).toBe(0);
|
|
2461
|
+
expect(getRootRedirect(db)).toBe("/app/");
|
|
2462
|
+
expect(logs.join("\n")).toMatch(/front page.*now opens the app at \/app\//);
|
|
2463
|
+
} finally {
|
|
2464
|
+
db.close();
|
|
2465
|
+
}
|
|
2466
|
+
} finally {
|
|
2467
|
+
cleanup();
|
|
2468
|
+
}
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
test("a pre-set root_redirect is left alone — never clobbered", async () => {
|
|
2472
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2473
|
+
try {
|
|
2474
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2475
|
+
const logs: string[] = [];
|
|
2476
|
+
try {
|
|
2477
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
2478
|
+
const code = await install("app", {
|
|
2479
|
+
runner: async () => 0,
|
|
2480
|
+
manifestPath: path,
|
|
2481
|
+
configDir,
|
|
2482
|
+
startService: async () => 0,
|
|
2483
|
+
isLinked: () => false,
|
|
2484
|
+
portProbe: async () => false,
|
|
2485
|
+
rootRedirectDb: db,
|
|
2486
|
+
log: (l) => logs.push(l),
|
|
2487
|
+
});
|
|
2488
|
+
expect(code).toBe(0);
|
|
2489
|
+
// The operator's prior choice survives byte-for-byte.
|
|
2490
|
+
expect(getRootRedirect(db)).toBe("/surface/reading-room");
|
|
2491
|
+
// The set-if-unset write's OWN log line (distinct from the static
|
|
2492
|
+
// postInstallFooter boilerplate, which always prints "now opens the
|
|
2493
|
+
// app too, unless you've already..." regardless of whether the write
|
|
2494
|
+
// fired) must NOT appear — the write itself was skipped.
|
|
2495
|
+
expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
|
|
2496
|
+
} finally {
|
|
2497
|
+
db.close();
|
|
2498
|
+
}
|
|
2499
|
+
} finally {
|
|
2500
|
+
cleanup();
|
|
2501
|
+
}
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
test("installing a different module never touches root_redirect", async () => {
|
|
2505
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2506
|
+
try {
|
|
2507
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2508
|
+
try {
|
|
2509
|
+
const code = await install("notes", {
|
|
2510
|
+
runner: async () => 0,
|
|
2511
|
+
manifestPath: path,
|
|
2512
|
+
configDir,
|
|
2513
|
+
startService: async () => 0,
|
|
2514
|
+
isLinked: () => false,
|
|
2515
|
+
portProbe: async () => false,
|
|
2516
|
+
rootRedirectDb: db,
|
|
2517
|
+
log: () => {},
|
|
2518
|
+
});
|
|
2519
|
+
expect(code).toBe(0);
|
|
2520
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
2521
|
+
} finally {
|
|
2522
|
+
db.close();
|
|
2523
|
+
}
|
|
2524
|
+
} finally {
|
|
2525
|
+
cleanup();
|
|
2526
|
+
}
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
test("an ENV-configured redirect (no DB row) is left alone — never clobbered (N1)", async () => {
|
|
2530
|
+
// Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
|
|
2531
|
+
// not a DB row. Gating on `getRootRedirect(db) === null` (DB only) would
|
|
2532
|
+
// miss this and write `/app/`, which — since the DB row wins over env on
|
|
2533
|
+
// read — silently overrides their configured landing. The fix gates on
|
|
2534
|
+
// `resolveRootRedirectDetailed(db).source === "default"`, which sees the
|
|
2535
|
+
// env tier. So: env set → no DB row written.
|
|
2536
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2537
|
+
const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2538
|
+
try {
|
|
2539
|
+
process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = "/surface/team-room";
|
|
2540
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2541
|
+
const logs: string[] = [];
|
|
2542
|
+
try {
|
|
2543
|
+
const code = await install("app", {
|
|
2544
|
+
runner: async () => 0,
|
|
2545
|
+
manifestPath: path,
|
|
2546
|
+
configDir,
|
|
2547
|
+
startService: async () => 0,
|
|
2548
|
+
isLinked: () => false,
|
|
2549
|
+
portProbe: async () => false,
|
|
2550
|
+
rootRedirectDb: db,
|
|
2551
|
+
log: (l) => logs.push(l),
|
|
2552
|
+
});
|
|
2553
|
+
expect(code).toBe(0);
|
|
2554
|
+
// No DB row written — the env-configured landing survives.
|
|
2555
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
2556
|
+
expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
|
|
2557
|
+
} finally {
|
|
2558
|
+
db.close();
|
|
2559
|
+
}
|
|
2560
|
+
} finally {
|
|
2561
|
+
if (prevEnv === undefined) {
|
|
2562
|
+
delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2563
|
+
} else {
|
|
2564
|
+
process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = prevEnv;
|
|
2565
|
+
}
|
|
2566
|
+
cleanup();
|
|
2567
|
+
}
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
|
|
2571
|
+
// Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
|
|
2572
|
+
// install.ts: a test with a tempdir manifestPath and no explicit opt-in
|
|
2573
|
+
// must never open the real ~/.parachute/hub.db. There's nothing to open
|
|
2574
|
+
// here at all — the assertion is simply that install still completes
|
|
2575
|
+
// cleanly with the DB-touching branch skipped.
|
|
2576
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2577
|
+
try {
|
|
2578
|
+
const code = await install("app", {
|
|
2579
|
+
runner: async () => 0,
|
|
2580
|
+
manifestPath: path,
|
|
2581
|
+
configDir,
|
|
2582
|
+
startService: async () => 0,
|
|
2583
|
+
isLinked: () => false,
|
|
2584
|
+
portProbe: async () => false,
|
|
2585
|
+
log: () => {},
|
|
2586
|
+
});
|
|
2587
|
+
expect(code).toBe(0);
|
|
2588
|
+
} finally {
|
|
2589
|
+
cleanup();
|
|
2590
|
+
}
|
|
2591
|
+
});
|
|
2592
|
+
});
|