@openparachute/hub 0.7.7-rc.9 → 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 +210 -38
- package/src/__tests__/migrate.test.ts +3 -1
- 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 +17 -26
- 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 +22 -92
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +8 -13
- 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 +9 -7
- 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 +72 -27
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +0 -2
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +0 -1
- 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/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 +18 -47
- package/src/services-manifest.ts +9 -42
- 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
|
@@ -12,30 +12,38 @@ import { join } from "node:path";
|
|
|
12
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
DEFAULT_ROOT_MODE,
|
|
15
16
|
DEFAULT_ROOT_REDIRECT,
|
|
16
17
|
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
17
18
|
MODULE_INSTALL_CHANNELS,
|
|
19
|
+
PARACHUTE_HUB_ROOT_MODE_ENV,
|
|
18
20
|
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
19
21
|
PARACHUTE_INSTALL_CHANNEL_ENV,
|
|
20
22
|
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
23
|
+
ROOT_MODES,
|
|
21
24
|
SETUP_EXPOSE_MODES,
|
|
22
25
|
consumeFirstClientAutoApproveWindow,
|
|
23
26
|
deleteSetting,
|
|
24
27
|
getHubOrigin,
|
|
25
28
|
getModuleInstallChannel,
|
|
29
|
+
getRootMode,
|
|
26
30
|
getRootRedirect,
|
|
27
31
|
getSetting,
|
|
28
32
|
isFirstClientAutoApproveWindowOpen,
|
|
29
33
|
isModuleInstallChannel,
|
|
30
34
|
isNotesRedirectDisabled,
|
|
35
|
+
isRootMode,
|
|
31
36
|
isSafeRedirectPath,
|
|
32
37
|
isSetupExposeMode,
|
|
33
38
|
openFirstClientAutoApproveWindow,
|
|
39
|
+
resolveRootMode,
|
|
40
|
+
resolveRootModeDetailed,
|
|
34
41
|
resolveRootRedirect,
|
|
35
42
|
resolveRootRedirectDetailed,
|
|
36
43
|
setHubOrigin,
|
|
37
44
|
setModuleInstallChannel,
|
|
38
45
|
setNotesRedirectDisabled,
|
|
46
|
+
setRootMode,
|
|
39
47
|
setRootRedirect,
|
|
40
48
|
setSetting,
|
|
41
49
|
} from "../hub-settings.ts";
|
|
@@ -363,9 +371,9 @@ describe("hub-settings — module install channel bootstrap", () => {
|
|
|
363
371
|
// First read with env=rc — env wins, returns "rc". DB stays empty
|
|
364
372
|
// (no auto-write to DB when env is set, per the design that
|
|
365
373
|
// preserves the SPA's last-write as the env-unset fallback).
|
|
366
|
-
expect(
|
|
367
|
-
|
|
368
|
-
)
|
|
374
|
+
expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
|
|
375
|
+
"rc",
|
|
376
|
+
);
|
|
369
377
|
// Second read with env=latest — env wins again (would have returned
|
|
370
378
|
// "rc" under the old DB-after-first-seed behavior).
|
|
371
379
|
expect(
|
|
@@ -386,9 +394,9 @@ describe("hub-settings — module install channel bootstrap", () => {
|
|
|
386
394
|
const db = openHubDb(hubDbPath(dir));
|
|
387
395
|
try {
|
|
388
396
|
// PARACHUTE_INSTALL_CHANNEL — canonical name (matches install.ts).
|
|
389
|
-
expect(
|
|
390
|
-
|
|
391
|
-
)
|
|
397
|
+
expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
|
|
398
|
+
"rc",
|
|
399
|
+
);
|
|
392
400
|
// PARACHUTE_MODULE_CHANNEL — legacy alias, still recognized.
|
|
393
401
|
const db2 = openHubDb(join(mkdtempSync(join(tmpdir(), "phub-hsalt-")), "hub.db"));
|
|
394
402
|
try {
|
|
@@ -801,3 +809,99 @@ describe("hub-settings — root_redirect storage + resolution", () => {
|
|
|
801
809
|
expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
|
|
802
810
|
});
|
|
803
811
|
});
|
|
812
|
+
|
|
813
|
+
describe("hub-settings — root_mode storage + resolution", () => {
|
|
814
|
+
let dir: string;
|
|
815
|
+
beforeEach(() => {
|
|
816
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-root-mode-"));
|
|
817
|
+
});
|
|
818
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
819
|
+
|
|
820
|
+
const noEnv: NodeJS.ProcessEnv = {};
|
|
821
|
+
const silent = () => {};
|
|
822
|
+
|
|
823
|
+
test("isRootMode + ROOT_MODES", () => {
|
|
824
|
+
expect(ROOT_MODES).toEqual(["redirect", "serve-app"]);
|
|
825
|
+
expect(isRootMode("redirect")).toBe(true);
|
|
826
|
+
expect(isRootMode("serve-app")).toBe(true);
|
|
827
|
+
expect(isRootMode("serveapp")).toBe(false);
|
|
828
|
+
expect(isRootMode(42)).toBe(false);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("getRootMode round-trips serve-app via setRootMode", () => {
|
|
832
|
+
const db = openHubDb(hubDbPath(dir));
|
|
833
|
+
try {
|
|
834
|
+
expect(getRootMode(db)).toBeNull();
|
|
835
|
+
setRootMode(db, "serve-app");
|
|
836
|
+
expect(getRootMode(db)).toBe("serve-app");
|
|
837
|
+
} finally {
|
|
838
|
+
db.close();
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("setRootMode(null) and setRootMode('redirect') both clear the row (default = absence)", () => {
|
|
843
|
+
const db = openHubDb(hubDbPath(dir));
|
|
844
|
+
try {
|
|
845
|
+
setRootMode(db, "serve-app");
|
|
846
|
+
setRootMode(db, null);
|
|
847
|
+
expect(getRootMode(db)).toBeNull();
|
|
848
|
+
setRootMode(db, "serve-app");
|
|
849
|
+
setRootMode(db, "redirect"); // the default is stored as absence
|
|
850
|
+
expect(getRootMode(db)).toBeNull();
|
|
851
|
+
expect(getSetting(db, "root_mode")).toBeUndefined();
|
|
852
|
+
} finally {
|
|
853
|
+
db.close();
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("resolves to the redirect default when neither DB nor env is set", () => {
|
|
858
|
+
const db = openHubDb(hubDbPath(dir));
|
|
859
|
+
try {
|
|
860
|
+
expect(resolveRootMode(db, { env: noEnv })).toBe(DEFAULT_ROOT_MODE);
|
|
861
|
+
expect(resolveRootModeDetailed(db, { env: noEnv })).toEqual({
|
|
862
|
+
value: "redirect",
|
|
863
|
+
source: "default",
|
|
864
|
+
});
|
|
865
|
+
} finally {
|
|
866
|
+
db.close();
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("env override applies when no DB row; DB overrides env (DB is tier-1)", () => {
|
|
871
|
+
const db = openHubDb(hubDbPath(dir));
|
|
872
|
+
try {
|
|
873
|
+
const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" };
|
|
874
|
+
expect(resolveRootModeDetailed(db, { env })).toEqual({ value: "serve-app", source: "env" });
|
|
875
|
+
setRootMode(db, "serve-app");
|
|
876
|
+
// A DB "serve-app" over an env "redirect" resolves to db.
|
|
877
|
+
expect(
|
|
878
|
+
resolveRootModeDetailed(db, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "redirect" } }),
|
|
879
|
+
).toEqual({ value: "serve-app", source: "db" });
|
|
880
|
+
} finally {
|
|
881
|
+
db.close();
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("an invalid DB row / env value is ignored → falls back to the redirect default", () => {
|
|
886
|
+
const db = openHubDb(hubDbPath(dir));
|
|
887
|
+
try {
|
|
888
|
+
setSetting(db, "root_mode", "bogus"); // hand-edited row bypassing write validation
|
|
889
|
+
expect(resolveRootMode(db, { env: noEnv, warn: silent })).toBe("redirect");
|
|
890
|
+
expect(getRootMode(db)).toBeNull(); // getRootMode also guards the invalid value
|
|
891
|
+
const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "nonsense" };
|
|
892
|
+
expect(resolveRootModeDetailed(db, { env, warn: silent })).toEqual({
|
|
893
|
+
value: "redirect",
|
|
894
|
+
source: "default",
|
|
895
|
+
});
|
|
896
|
+
} finally {
|
|
897
|
+
db.close();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("a null db (no state) resolves from env / default only", () => {
|
|
902
|
+
expect(resolveRootMode(null, { env: noEnv })).toBe("redirect");
|
|
903
|
+
expect(resolveRootMode(null, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" } })).toBe(
|
|
904
|
+
"serve-app",
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
@@ -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({
|
|
@@ -5,8 +5,11 @@ import { join } from "node:path";
|
|
|
5
5
|
import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
|
|
6
6
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
7
|
import {
|
|
8
|
+
PARACHUTE_HUB_ROOT_MODE_ENV,
|
|
8
9
|
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
10
|
+
getRootMode,
|
|
9
11
|
getRootRedirect,
|
|
12
|
+
setRootMode,
|
|
10
13
|
setRootRedirect,
|
|
11
14
|
} from "../hub-settings.ts";
|
|
12
15
|
import { findService, upsertService } from "../services-manifest.ts";
|
|
@@ -68,6 +71,71 @@ describe("install", () => {
|
|
|
68
71
|
}
|
|
69
72
|
});
|
|
70
73
|
|
|
74
|
+
test("refuses `install agent` without invoking bun or the deprecated binary", async () => {
|
|
75
|
+
const { path, cleanup } = makeTempPath();
|
|
76
|
+
try {
|
|
77
|
+
const calls: string[][] = [];
|
|
78
|
+
const logs: string[] = [];
|
|
79
|
+
const code = await install("agent", {
|
|
80
|
+
runner: async (cmd) => {
|
|
81
|
+
calls.push([...cmd]);
|
|
82
|
+
return 0;
|
|
83
|
+
},
|
|
84
|
+
manifestPath: path,
|
|
85
|
+
startService: async () => 0,
|
|
86
|
+
isLinked: () => false,
|
|
87
|
+
portProbe: async () => false,
|
|
88
|
+
log: (line) => logs.push(line),
|
|
89
|
+
});
|
|
90
|
+
expect(code).toBe(1);
|
|
91
|
+
expect(calls).toEqual([]);
|
|
92
|
+
expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
|
|
93
|
+
expect(logs.join("\n")).toMatch(/Vault and Surface/);
|
|
94
|
+
} finally {
|
|
95
|
+
cleanup();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("refuses explicit retired Agent npm package names before invoking bun", async () => {
|
|
100
|
+
for (const packageName of [
|
|
101
|
+
"@openparachute/agent",
|
|
102
|
+
"@openparachute/agent@0.9.0",
|
|
103
|
+
"@openparachute/channel",
|
|
104
|
+
]) {
|
|
105
|
+
const calls: string[][] = [];
|
|
106
|
+
const logs: string[] = [];
|
|
107
|
+
const code = await install(packageName, {
|
|
108
|
+
runner: async (cmd) => {
|
|
109
|
+
calls.push([...cmd]);
|
|
110
|
+
return 0;
|
|
111
|
+
},
|
|
112
|
+
log: (line) => logs.push(line),
|
|
113
|
+
});
|
|
114
|
+
expect(code).toBe(1);
|
|
115
|
+
expect(calls).toEqual([]);
|
|
116
|
+
expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("refuses a local checkout whose package metadata is a retired Agent package", async () => {
|
|
121
|
+
const pkgDir = mkdtempSync(join(tmpdir(), "pcli-retired-agent-"));
|
|
122
|
+
try {
|
|
123
|
+
const calls: string[][] = [];
|
|
124
|
+
const code = await install(pkgDir, {
|
|
125
|
+
runner: async (cmd) => {
|
|
126
|
+
calls.push([...cmd]);
|
|
127
|
+
return 0;
|
|
128
|
+
},
|
|
129
|
+
readPackageName: () => "@openparachute/agent",
|
|
130
|
+
log: () => {},
|
|
131
|
+
});
|
|
132
|
+
expect(code).toBe(1);
|
|
133
|
+
expect(calls).toEqual([]);
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
71
139
|
test("runs bun add -g then init; seeds manifest when service didn't write one", async () => {
|
|
72
140
|
const { path, cleanup } = makeTempPath();
|
|
73
141
|
try {
|
|
@@ -1642,10 +1710,10 @@ describe("install", () => {
|
|
|
1642
1710
|
log: (l) => logs.push(l),
|
|
1643
1711
|
});
|
|
1644
1712
|
expect(code).toBe(0);
|
|
1645
|
-
//
|
|
1646
|
-
// canonical
|
|
1713
|
+
// Agent retirement released 1941, so it is now the first unassigned
|
|
1714
|
+
// canonical slot available to the fallback walker.
|
|
1647
1715
|
const entry = findService("parachute-vault", path);
|
|
1648
|
-
expect(entry?.port).toBe(
|
|
1716
|
+
expect(entry?.port).toBe(1941);
|
|
1649
1717
|
expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
|
|
1650
1718
|
// .env is not touched.
|
|
1651
1719
|
const envPath = join(configDir, "vault", ".env");
|
|
@@ -1872,6 +1940,33 @@ describe("install", () => {
|
|
|
1872
1940
|
}
|
|
1873
1941
|
});
|
|
1874
1942
|
|
|
1943
|
+
test("refuses third-party metadata that declares a retired Agent manifest identity", async () => {
|
|
1944
|
+
const { path, cleanup } = makeTempPath();
|
|
1945
|
+
try {
|
|
1946
|
+
const logs: string[] = [];
|
|
1947
|
+
const code = await install("@vendor/agent-wrapper", {
|
|
1948
|
+
runner: async () => 0,
|
|
1949
|
+
manifestPath: path,
|
|
1950
|
+
isLinked: () => false,
|
|
1951
|
+
log: (line) => logs.push(line),
|
|
1952
|
+
readManifest: async () => ({
|
|
1953
|
+
name: "claw",
|
|
1954
|
+
manifestName: "@vendor/agent-wrapper",
|
|
1955
|
+
port: 1952,
|
|
1956
|
+
paths: ["/claw"],
|
|
1957
|
+
health: "/claw/health",
|
|
1958
|
+
}),
|
|
1959
|
+
findGlobalInstall: () =>
|
|
1960
|
+
"/fake/bun-globals/node_modules/@vendor/agent-wrapper/package.json",
|
|
1961
|
+
});
|
|
1962
|
+
expect(code).toBe(1);
|
|
1963
|
+
expect(findService("claw", path)).toBeUndefined();
|
|
1964
|
+
expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
|
|
1965
|
+
} finally {
|
|
1966
|
+
cleanup();
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1875
1970
|
test("npm-installed third-party module persists installDir from bun globals", async () => {
|
|
1876
1971
|
// hub#83: for `parachute install <npm-pkg>`, installDir is dirname of
|
|
1877
1972
|
// the package.json that findGlobalInstall returns. Without this,
|
|
@@ -2325,31 +2420,38 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
|
|
|
2325
2420
|
});
|
|
2326
2421
|
});
|
|
2327
2422
|
|
|
2328
|
-
//
|
|
2329
|
-
//
|
|
2330
|
-
//
|
|
2331
|
-
//
|
|
2332
|
-
//
|
|
2423
|
+
// `parachute install app` defaults the hub's origin root to SERVE the app
|
|
2424
|
+
// (root_mode = serve-app) on first install — SET-IF-UNSET ONLY, and only when
|
|
2425
|
+
// the root behavior is entirely pristine default (no mode row/env AND no
|
|
2426
|
+
// redirect row/env). Supersedes the prior hub-parity P5 behavior (which wrote
|
|
2427
|
+
// root_redirect = /app/). Every test here injects `rootRedirectDb` (a hub.db
|
|
2428
|
+
// opened in the same disposable tempdir `makeTempPath()` already isolates) so no
|
|
2429
|
+
// real `~/.parachute/hub.db` is ever touched — never drive this against the
|
|
2333
2430
|
// operator's live install.
|
|
2334
|
-
describe("app-only
|
|
2335
|
-
// The write gates on `
|
|
2336
|
-
//
|
|
2337
|
-
//
|
|
2338
|
-
//
|
|
2339
|
-
let
|
|
2431
|
+
describe("app-only serve-app set-if-unset default", () => {
|
|
2432
|
+
// The write gates on the RESOLVED mode + redirect being `default` (no DB row,
|
|
2433
|
+
// no env). Neutralize any ambient PARACHUTE_HUB_ROOT_MODE / _REDIRECT (Aaron's
|
|
2434
|
+
// box, CI) so the default-tier tests are deterministic; the env-set tests
|
|
2435
|
+
// below manage their own values inside their own bodies.
|
|
2436
|
+
let savedRedirectEnv: string | undefined;
|
|
2437
|
+
let savedModeEnv: string | undefined;
|
|
2340
2438
|
beforeEach(() => {
|
|
2341
|
-
|
|
2439
|
+
savedRedirectEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2440
|
+
savedModeEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2342
2441
|
delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2442
|
+
delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2343
2443
|
});
|
|
2344
2444
|
afterEach(() => {
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2445
|
+
for (const [name, saved] of [
|
|
2446
|
+
[PARACHUTE_HUB_ROOT_REDIRECT_ENV, savedRedirectEnv],
|
|
2447
|
+
[PARACHUTE_HUB_ROOT_MODE_ENV, savedModeEnv],
|
|
2448
|
+
] as const) {
|
|
2449
|
+
if (saved === undefined) delete process.env[name];
|
|
2450
|
+
else process.env[name] = saved;
|
|
2349
2451
|
}
|
|
2350
2452
|
});
|
|
2351
2453
|
|
|
2352
|
-
test("fresh install (
|
|
2454
|
+
test("fresh install (pristine root) flips root_mode to serve-app", async () => {
|
|
2353
2455
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2354
2456
|
try {
|
|
2355
2457
|
const db = openHubDb(hubDbPath(configDir));
|
|
@@ -2366,8 +2468,10 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2366
2468
|
log: (l) => logs.push(l),
|
|
2367
2469
|
});
|
|
2368
2470
|
expect(code).toBe(0);
|
|
2369
|
-
expect(
|
|
2370
|
-
|
|
2471
|
+
expect(getRootMode(db)).toBe("serve-app");
|
|
2472
|
+
// The redirect row is NOT written — serve-app takes over `/` directly.
|
|
2473
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
2474
|
+
expect(logs.join("\n")).toMatch(/front page.*now serves the Parachute app/);
|
|
2371
2475
|
} finally {
|
|
2372
2476
|
db.close();
|
|
2373
2477
|
}
|
|
@@ -2376,7 +2480,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2376
2480
|
}
|
|
2377
2481
|
});
|
|
2378
2482
|
|
|
2379
|
-
test("a pre-set root_redirect is left alone —
|
|
2483
|
+
test("a pre-set root_redirect is left alone — mode NOT flipped", async () => {
|
|
2380
2484
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2381
2485
|
try {
|
|
2382
2486
|
const db = openHubDb(hubDbPath(configDir));
|
|
@@ -2394,13 +2498,10 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2394
2498
|
log: (l) => logs.push(l),
|
|
2395
2499
|
});
|
|
2396
2500
|
expect(code).toBe(0);
|
|
2397
|
-
// The operator's prior choice survives byte-for-byte.
|
|
2501
|
+
// The operator's prior choice survives byte-for-byte; no mode flip.
|
|
2398
2502
|
expect(getRootRedirect(db)).toBe("/surface/reading-room");
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
// app too, unless you've already..." regardless of whether the write
|
|
2402
|
-
// fired) must NOT appear — the write itself was skipped.
|
|
2403
|
-
expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
|
|
2503
|
+
expect(getRootMode(db)).toBeNull();
|
|
2504
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2404
2505
|
} finally {
|
|
2405
2506
|
db.close();
|
|
2406
2507
|
}
|
|
@@ -2409,7 +2510,40 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2409
2510
|
}
|
|
2410
2511
|
});
|
|
2411
2512
|
|
|
2412
|
-
test("
|
|
2513
|
+
test("an existing install with the old /app/ redirect keeps redirecting (back-compat)", async () => {
|
|
2514
|
+
// A hub that ran the PRIOR app-install code has root_redirect = /app/ in its
|
|
2515
|
+
// DB (source = db → not default). Re-running `install app` must NOT flip it
|
|
2516
|
+
// to serve-app — it keeps 302-ing to /app/, exactly as before. This is the
|
|
2517
|
+
// "existing installs unchanged" guarantee.
|
|
2518
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2519
|
+
try {
|
|
2520
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2521
|
+
const logs: string[] = [];
|
|
2522
|
+
try {
|
|
2523
|
+
setRootRedirect(db, "/app/");
|
|
2524
|
+
const code = await install("app", {
|
|
2525
|
+
runner: async () => 0,
|
|
2526
|
+
manifestPath: path,
|
|
2527
|
+
configDir,
|
|
2528
|
+
startService: async () => 0,
|
|
2529
|
+
isLinked: () => false,
|
|
2530
|
+
portProbe: async () => false,
|
|
2531
|
+
rootRedirectDb: db,
|
|
2532
|
+
log: (l) => logs.push(l),
|
|
2533
|
+
});
|
|
2534
|
+
expect(code).toBe(0);
|
|
2535
|
+
expect(getRootMode(db)).toBeNull();
|
|
2536
|
+
expect(getRootRedirect(db)).toBe("/app/");
|
|
2537
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2538
|
+
} finally {
|
|
2539
|
+
db.close();
|
|
2540
|
+
}
|
|
2541
|
+
} finally {
|
|
2542
|
+
cleanup();
|
|
2543
|
+
}
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
test("installing a different module never touches the root settings", async () => {
|
|
2413
2547
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2414
2548
|
try {
|
|
2415
2549
|
const db = openHubDb(hubDbPath(configDir));
|
|
@@ -2426,6 +2560,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2426
2560
|
});
|
|
2427
2561
|
expect(code).toBe(0);
|
|
2428
2562
|
expect(getRootRedirect(db)).toBeNull();
|
|
2563
|
+
expect(getRootMode(db)).toBeNull();
|
|
2429
2564
|
} finally {
|
|
2430
2565
|
db.close();
|
|
2431
2566
|
}
|
|
@@ -2434,13 +2569,12 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2434
2569
|
}
|
|
2435
2570
|
});
|
|
2436
2571
|
|
|
2437
|
-
test("an ENV-configured redirect (no DB row) is left alone —
|
|
2572
|
+
test("an ENV-configured redirect (no DB row) is left alone — no mode flip (N1)", async () => {
|
|
2438
2573
|
// Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
|
|
2439
|
-
// not a DB row.
|
|
2440
|
-
//
|
|
2441
|
-
//
|
|
2442
|
-
// `
|
|
2443
|
-
// env tier. So: env set → no DB row written.
|
|
2574
|
+
// not a DB row. Flipping to serve-app would silently override their
|
|
2575
|
+
// configured landing (serve-app wins over redirect at `/`). The gate reads
|
|
2576
|
+
// the RESOLVED redirect source; with the env set that's `env`, not
|
|
2577
|
+
// `default`, so we leave it alone.
|
|
2444
2578
|
const { path, configDir, cleanup } = makeTempPath();
|
|
2445
2579
|
const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
2446
2580
|
try {
|
|
@@ -2459,9 +2593,10 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2459
2593
|
log: (l) => logs.push(l),
|
|
2460
2594
|
});
|
|
2461
2595
|
expect(code).toBe(0);
|
|
2462
|
-
// No
|
|
2596
|
+
// No mode row written — the env-configured landing survives.
|
|
2597
|
+
expect(getRootMode(db)).toBeNull();
|
|
2463
2598
|
expect(getRootRedirect(db)).toBeNull();
|
|
2464
|
-
expect(logs.join("\n")).not.toMatch(/now
|
|
2599
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2465
2600
|
} finally {
|
|
2466
2601
|
db.close();
|
|
2467
2602
|
}
|
|
@@ -2475,6 +2610,43 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
|
|
|
2475
2610
|
}
|
|
2476
2611
|
});
|
|
2477
2612
|
|
|
2613
|
+
test("an ENV-configured mode (no DB row) is left alone — no clobber", async () => {
|
|
2614
|
+
// PARACHUTE_HUB_ROOT_MODE=redirect on a container that deliberately wants
|
|
2615
|
+
// the redirect front door. The gate sees the resolved mode source as `env`,
|
|
2616
|
+
// not `default`, so it does NOT write a serve-app row.
|
|
2617
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
2618
|
+
const prevEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2619
|
+
try {
|
|
2620
|
+
process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = "redirect";
|
|
2621
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
2622
|
+
const logs: string[] = [];
|
|
2623
|
+
try {
|
|
2624
|
+
const code = await install("app", {
|
|
2625
|
+
runner: async () => 0,
|
|
2626
|
+
manifestPath: path,
|
|
2627
|
+
configDir,
|
|
2628
|
+
startService: async () => 0,
|
|
2629
|
+
isLinked: () => false,
|
|
2630
|
+
portProbe: async () => false,
|
|
2631
|
+
rootRedirectDb: db,
|
|
2632
|
+
log: (l) => logs.push(l),
|
|
2633
|
+
});
|
|
2634
|
+
expect(code).toBe(0);
|
|
2635
|
+
expect(getRootMode(db)).toBeNull();
|
|
2636
|
+
expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
|
|
2637
|
+
} finally {
|
|
2638
|
+
db.close();
|
|
2639
|
+
}
|
|
2640
|
+
} finally {
|
|
2641
|
+
if (prevEnv === undefined) {
|
|
2642
|
+
delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
|
|
2643
|
+
} else {
|
|
2644
|
+
process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = prevEnv;
|
|
2645
|
+
}
|
|
2646
|
+
cleanup();
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2478
2650
|
test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
|
|
2479
2651
|
// Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
|
|
2480
2652
|
// install.ts: a test with a tempdir manifestPath and no explicit opt-in
|
|
@@ -56,7 +56,9 @@ describe("safelistEntries", () => {
|
|
|
56
56
|
expect(s.has("vault")).toBe(true);
|
|
57
57
|
expect(s.has("notes")).toBe(true);
|
|
58
58
|
expect(s.has("scribe")).toBe(true);
|
|
59
|
-
expect(s.has("agent")).toBe(
|
|
59
|
+
expect(s.has("agent")).toBe(false);
|
|
60
|
+
expect(KNOWN_ARCHIVABLE_DIRS.has("agent")).toBe(true);
|
|
61
|
+
expect(KNOWN_ARCHIVABLE_DIRS.has("channel")).toBe(true);
|
|
60
62
|
// Internal
|
|
61
63
|
expect(s.has("hub")).toBe(true);
|
|
62
64
|
// CLI state
|
|
@@ -3,6 +3,11 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
checkAuthorizationServerMetadata,
|
|
8
|
+
checkProtectedResourceMetadata,
|
|
9
|
+
checkTokenResponseInvariants,
|
|
10
|
+
} from "@openparachute/door-contract";
|
|
6
11
|
import { handleAdminLoginPost, handleAdminLoginTotpPost } from "../admin-handlers.ts";
|
|
7
12
|
import { approveClient, getClient, registerClient } from "../clients.ts";
|
|
8
13
|
import { CSRF_COOKIE_NAME } from "../csrf.ts";
|
|
@@ -122,9 +127,12 @@ describe("authorizationServerMetadata", () => {
|
|
|
122
127
|
// hub:admin + scribe:admin are operator-only (non-requestable) — never advertised (2026-06-30)
|
|
123
128
|
expect(scopesSupported).not.toContain("hub:admin");
|
|
124
129
|
expect(scopesSupported).not.toContain("scribe:admin");
|
|
125
|
-
//
|
|
126
|
-
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
130
|
+
// Retired Agent scopes are no longer part of the first-party catalog.
|
|
127
131
|
expect(scopesSupported).not.toContain("agent:send");
|
|
132
|
+
// H1.2 — door-contract conformance: the static RFC 8414 fields (endpoints,
|
|
133
|
+
// response/grant types, PKCE method, token-auth methods) match the shared
|
|
134
|
+
// contract exactly (V1.4/C1.4 twin coverage, hub half).
|
|
135
|
+
expect(checkAuthorizationServerMetadata(body, ISSUER, scopesSupported)).toEqual([]);
|
|
128
136
|
});
|
|
129
137
|
|
|
130
138
|
test("does NOT advertise non-requestable operator-only scopes", async () => {
|
|
@@ -164,7 +172,7 @@ describe("authorizationServerMetadata", () => {
|
|
|
164
172
|
const body = (await res.json()) as Record<string, unknown>;
|
|
165
173
|
const scopesSupported = body.scopes_supported as string[];
|
|
166
174
|
// Third-party scopes show up (`widget:*` / `mymodule:*` aren't gated
|
|
167
|
-
// optional-module prefixes — only scribe:/
|
|
175
|
+
// optional-module prefixes — only scribe:/surface: are, see OPTIONAL_MODULE_SCOPES).
|
|
168
176
|
expect(scopesSupported).toContain("widget:read");
|
|
169
177
|
expect(scopesSupported).toContain("widget:write");
|
|
170
178
|
expect(scopesSupported).toContain("mymodule:do-thing");
|
|
@@ -178,10 +186,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
178
186
|
});
|
|
179
187
|
|
|
180
188
|
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
181
|
-
// FIRST_PARTY_SCOPES carries scribe:*
|
|
189
|
+
// FIRST_PARTY_SCOPES carries scribe:* statically. On a
|
|
182
190
|
// vault-only hub they must NOT be advertised — a discovery client (e.g.
|
|
183
191
|
// claude.ai's connector UI) lists the catalog verbatim, so a friend
|
|
184
|
-
// connecting one vault was shown Scribe
|
|
192
|
+
// connecting one vault was shown Scribe access the hub can't
|
|
185
193
|
// honor. Vault + hub are core and always advertised.
|
|
186
194
|
const declared = new Set<string>([
|
|
187
195
|
"vault:read",
|
|
@@ -189,7 +197,6 @@ describe("authorizationServerMetadata", () => {
|
|
|
189
197
|
"vault:admin",
|
|
190
198
|
"scribe:transcribe",
|
|
191
199
|
"scribe:admin",
|
|
192
|
-
"agent:send",
|
|
193
200
|
"hub:admin",
|
|
194
201
|
]);
|
|
195
202
|
const vaultOnly = {
|
|
@@ -217,7 +224,6 @@ describe("authorizationServerMetadata", () => {
|
|
|
217
224
|
// uninstalled optional-module scopes are dropped
|
|
218
225
|
expect(scopes).not.toContain("scribe:transcribe");
|
|
219
226
|
expect(scopes).not.toContain("scribe:admin");
|
|
220
|
-
expect(scopes).not.toContain("agent:send");
|
|
221
227
|
|
|
222
228
|
// ...but once scribe is installed, its scopes ARE advertised again.
|
|
223
229
|
const withScribe = {
|
|
@@ -249,7 +255,6 @@ describe("authorizationServerMetadata", () => {
|
|
|
249
255
|
// requestability gate (non-requestable, 2026-06-30) doing the work here, not
|
|
250
256
|
// the optional-module-not-installed gate that drops scribe:transcribe above.
|
|
251
257
|
expect(scopes2).not.toContain("scribe:admin");
|
|
252
|
-
expect(scopes2).not.toContain("agent:send"); // agent still not installed
|
|
253
258
|
});
|
|
254
259
|
});
|
|
255
260
|
|
|
@@ -265,6 +270,8 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
|
265
270
|
expect(body.resource).toBe(ISSUER);
|
|
266
271
|
expect(body.authorization_servers).toEqual([ISSUER]);
|
|
267
272
|
expect(body.bearer_methods_supported).toEqual(["header"]);
|
|
273
|
+
// H1.2 — door-contract conformance (V1.4/C1.4 twin coverage, hub half).
|
|
274
|
+
expect(checkProtectedResourceMetadata(body, ISSUER)).toEqual([]);
|
|
268
275
|
expect(Array.isArray(body.scopes_supported)).toBe(true);
|
|
269
276
|
expect(body.resource_documentation).toMatch(/parachute\.computer/);
|
|
270
277
|
});
|
|
@@ -2320,6 +2327,10 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
2320
2327
|
expect(tokenBody.token_type).toBe("Bearer");
|
|
2321
2328
|
expect(tokenBody.scope).toBe("vault:default:read");
|
|
2322
2329
|
expect(tokenBody.refresh_token.length).toBeGreaterThan(20);
|
|
2330
|
+
// H1.2 — door-contract conformance: token_type/expires_in/scope/access_token
|
|
2331
|
+
// invariants against a REAL `POST /oauth/token` success body (V1.4/C1.4 twin
|
|
2332
|
+
// coverage, hub half).
|
|
2333
|
+
expect(checkTokenResponseInvariants(tokenBody, "vault:default:read")).toEqual([]);
|
|
2323
2334
|
|
|
2324
2335
|
// JWT must verify against the hub's signing keys, with the right sub +
|
|
2325
2336
|
// aud (named `vault:default:read` → "vault.default" — RFC 8707-style
|