@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { writePid } from "../process-state.ts";
|
|
6
|
+
import { type ClassifyOpts, classifyUpstream } from "../proxy-state.ts";
|
|
7
|
+
import type { ModuleState, Supervisor } from "../supervisor.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stub supervisor — only `get(short)` is exercised by `classifyUpstream`.
|
|
11
|
+
* We construct it directly instead of standing up a real `Supervisor`
|
|
12
|
+
* + driving the spawn lifecycle, so test cases stay focused on the
|
|
13
|
+
* classifier's per-status branching.
|
|
14
|
+
*/
|
|
15
|
+
function stubSupervisor(states: Record<string, ModuleState>): Supervisor {
|
|
16
|
+
return {
|
|
17
|
+
get: (short: string) => states[short],
|
|
18
|
+
list: () => Object.values(states),
|
|
19
|
+
// Unused by classifyUpstream — present to satisfy the Supervisor type.
|
|
20
|
+
start: async () => {
|
|
21
|
+
throw new Error("not implemented");
|
|
22
|
+
},
|
|
23
|
+
stop: async () => undefined,
|
|
24
|
+
restart: async () => undefined,
|
|
25
|
+
} as unknown as Supervisor;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
|
|
29
|
+
return {
|
|
30
|
+
status: "running",
|
|
31
|
+
restartsInWindow: 0,
|
|
32
|
+
...partial,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("classifyUpstream — supervisor mode", () => {
|
|
37
|
+
test("status=starting → transient", () => {
|
|
38
|
+
const sup = stubSupervisor({
|
|
39
|
+
vault: moduleState({ short: "vault", status: "starting" }),
|
|
40
|
+
});
|
|
41
|
+
expect(classifyUpstream("vault", { supervisor: sup })).toBe("transient");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("status=restarting → transient", () => {
|
|
45
|
+
const sup = stubSupervisor({
|
|
46
|
+
vault: moduleState({ short: "vault", status: "restarting" }),
|
|
47
|
+
});
|
|
48
|
+
expect(classifyUpstream("vault", { supervisor: sup })).toBe("transient");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("status=crashed → persistent", () => {
|
|
52
|
+
const sup = stubSupervisor({
|
|
53
|
+
vault: moduleState({ short: "vault", status: "crashed" }),
|
|
54
|
+
});
|
|
55
|
+
expect(classifyUpstream("vault", { supervisor: sup })).toBe("persistent");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("status=stopped → persistent", () => {
|
|
59
|
+
const sup = stubSupervisor({
|
|
60
|
+
vault: moduleState({ short: "vault", status: "stopped" }),
|
|
61
|
+
});
|
|
62
|
+
expect(classifyUpstream("vault", { supervisor: sup })).toBe("persistent");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("status=running, inside boot window → transient", () => {
|
|
66
|
+
const now = 1_700_000_000_000;
|
|
67
|
+
const startedAt = new Date(now - 10_000).toISOString(); // 10s ago
|
|
68
|
+
const sup = stubSupervisor({
|
|
69
|
+
vault: moduleState({ short: "vault", status: "running", startedAt }),
|
|
70
|
+
});
|
|
71
|
+
expect(classifyUpstream("vault", { supervisor: sup, now: () => now })).toBe("transient");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("status=running, outside boot window → persistent", () => {
|
|
75
|
+
const now = 1_700_000_000_000;
|
|
76
|
+
const startedAt = new Date(now - 60_000).toISOString(); // 60s ago
|
|
77
|
+
const sup = stubSupervisor({
|
|
78
|
+
vault: moduleState({ short: "vault", status: "running", startedAt }),
|
|
79
|
+
});
|
|
80
|
+
expect(classifyUpstream("vault", { supervisor: sup, now: () => now })).toBe("persistent");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("status=running, exactly at boot-window boundary → persistent", () => {
|
|
84
|
+
// The check is strict-less-than, so exactly 30s falls into persistent.
|
|
85
|
+
const now = 1_700_000_000_000;
|
|
86
|
+
const startedAt = new Date(now - 30_000).toISOString();
|
|
87
|
+
const sup = stubSupervisor({
|
|
88
|
+
vault: moduleState({ short: "vault", status: "running", startedAt }),
|
|
89
|
+
});
|
|
90
|
+
expect(classifyUpstream("vault", { supervisor: sup, now: () => now })).toBe("persistent");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("status=running, missing startedAt → persistent", () => {
|
|
94
|
+
// Can't classify a running module without a start time; safer to call
|
|
95
|
+
// persistent and let the operator hit refresh than to lie that it's
|
|
96
|
+
// booting.
|
|
97
|
+
const sup = stubSupervisor({
|
|
98
|
+
vault: moduleState({ short: "vault", status: "running" }),
|
|
99
|
+
});
|
|
100
|
+
expect(classifyUpstream("vault", { supervisor: sup })).toBe("persistent");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("custom boot window honored", () => {
|
|
104
|
+
const now = 1_700_000_000_000;
|
|
105
|
+
const startedAt = new Date(now - 5_000).toISOString(); // 5s ago
|
|
106
|
+
const sup = stubSupervisor({
|
|
107
|
+
vault: moduleState({ short: "vault", status: "running", startedAt }),
|
|
108
|
+
});
|
|
109
|
+
expect(
|
|
110
|
+
classifyUpstream("vault", { supervisor: sup, now: () => now, bootWindowMs: 2_000 }),
|
|
111
|
+
).toBe("persistent");
|
|
112
|
+
expect(
|
|
113
|
+
classifyUpstream("vault", { supervisor: sup, now: () => now, bootWindowMs: 10_000 }),
|
|
114
|
+
).toBe("transient");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("module not tracked → falls back to pidfile path", () => {
|
|
118
|
+
// Empty supervisor map. Classifier must call through to processState;
|
|
119
|
+
// we inject a stub via readProcessState.
|
|
120
|
+
const sup = stubSupervisor({});
|
|
121
|
+
const opts: ClassifyOpts = {
|
|
122
|
+
supervisor: sup,
|
|
123
|
+
readProcessState: () => ({ status: "unknown" }),
|
|
124
|
+
};
|
|
125
|
+
expect(classifyUpstream("vault", opts)).toBe("persistent");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("classifyUpstream — on-box CLI mode (no supervisor)", () => {
|
|
130
|
+
test("running pidfile inside boot window → transient", () => {
|
|
131
|
+
const now = 1_700_000_000_000;
|
|
132
|
+
const opts: ClassifyOpts = {
|
|
133
|
+
now: () => now,
|
|
134
|
+
readProcessState: () => ({
|
|
135
|
+
status: "running",
|
|
136
|
+
pid: 12345,
|
|
137
|
+
startedAt: new Date(now - 5_000), // 5s old pidfile
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
expect(classifyUpstream("vault", opts)).toBe("transient");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("running pidfile outside boot window → persistent", () => {
|
|
144
|
+
const now = 1_700_000_000_000;
|
|
145
|
+
const opts: ClassifyOpts = {
|
|
146
|
+
now: () => now,
|
|
147
|
+
readProcessState: () => ({
|
|
148
|
+
status: "running",
|
|
149
|
+
pid: 12345,
|
|
150
|
+
startedAt: new Date(now - 60_000),
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
expect(classifyUpstream("vault", opts)).toBe("persistent");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("stopped pidfile (stale) → persistent", () => {
|
|
157
|
+
const opts: ClassifyOpts = {
|
|
158
|
+
readProcessState: () => ({ status: "stopped", pid: 12345 }),
|
|
159
|
+
};
|
|
160
|
+
expect(classifyUpstream("vault", opts)).toBe("persistent");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("no pidfile (unknown) → persistent", () => {
|
|
164
|
+
const opts: ClassifyOpts = {
|
|
165
|
+
readProcessState: () => ({ status: "unknown" }),
|
|
166
|
+
};
|
|
167
|
+
expect(classifyUpstream("vault", opts)).toBe("persistent");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("readProcessState throws → persistent (defensive)", () => {
|
|
171
|
+
// pidfile read can race with cleanup. Don't blow up the proxy.
|
|
172
|
+
const opts: ClassifyOpts = {
|
|
173
|
+
readProcessState: () => {
|
|
174
|
+
throw new Error("ENOENT");
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
expect(classifyUpstream("vault", opts)).toBe("persistent");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("integration with real processState — running pid is alive + fresh mtime", () => {
|
|
181
|
+
// Write a real pidfile pointing at this test process (always alive),
|
|
182
|
+
// so `defaultAlive` returns true. Pidfile mtime will be ~now, so it
|
|
183
|
+
// falls inside the boot window.
|
|
184
|
+
const dir = mkdtempSync(join(tmpdir(), "proxy-state-"));
|
|
185
|
+
try {
|
|
186
|
+
writePid("vault", process.pid, dir);
|
|
187
|
+
expect(classifyUpstream("vault", { configDir: dir })).toBe("transient");
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { narrowResourceVaultScopes, resolveResourceVault } from "../resource-binding.ts";
|
|
3
|
+
|
|
4
|
+
const ORIGIN = "https://hub.example";
|
|
5
|
+
const BOUND = [ORIGIN, "http://127.0.0.1:1939"];
|
|
6
|
+
|
|
7
|
+
describe("resolveResourceVault", () => {
|
|
8
|
+
test("resolves a per-vault MCP resource to the vault name", () => {
|
|
9
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp`, BOUND)).toBe("jon");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("tolerates a trailing slash on the MCP path", () => {
|
|
13
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/`, BOUND)).toBe("jon");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("ignores query string + fragment", () => {
|
|
17
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp?x=1#y`, BOUND)).toBe("jon");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("resolves the PRM document URL to the vault name", () => {
|
|
21
|
+
expect(
|
|
22
|
+
resolveResourceVault(`${ORIGIN}/vault/jon/.well-known/oauth-protected-resource`, BOUND),
|
|
23
|
+
).toBe("jon");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("resolves against a non-issuer bound origin (loopback)", () => {
|
|
27
|
+
expect(resolveResourceVault("http://127.0.0.1:1939/vault/work/mcp", BOUND)).toBe("work");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns null for an off-origin resource (not one we front)", () => {
|
|
31
|
+
expect(resolveResourceVault("https://evil.example/vault/jon/mcp", BOUND)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns null for a non-vault path", () => {
|
|
35
|
+
expect(resolveResourceVault(`${ORIGIN}/scribe/mcp`, BOUND)).toBeNull();
|
|
36
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon`, BOUND)).toBeNull();
|
|
37
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/notes`, BOUND)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns null for absent / empty / malformed resource", () => {
|
|
41
|
+
expect(resolveResourceVault(null, BOUND)).toBeNull();
|
|
42
|
+
expect(resolveResourceVault(undefined, BOUND)).toBeNull();
|
|
43
|
+
expect(resolveResourceVault("", BOUND)).toBeNull();
|
|
44
|
+
expect(resolveResourceVault("not a url", BOUND)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not collapse a deeper vault sub-path into the MCP shape", () => {
|
|
48
|
+
// `/vault/jon/mcp/extra` is not the canonical MCP endpoint.
|
|
49
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/extra`, BOUND)).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("rejects a vault segment that isn't a well-formed vault name (no junk mint)", () => {
|
|
53
|
+
// A crafted `resource=…/vault/%2F..%2Fadmin/mcp` decodes to `/../admin`,
|
|
54
|
+
// which is not `[a-zA-Z0-9_-]+`. Returning null falls through to the
|
|
55
|
+
// unbound flow — no narrowing, no token stamped `aud=vault./../admin`.
|
|
56
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/%2F..%2Fadmin/mcp`, BOUND)).toBeNull();
|
|
57
|
+
// Spaces / dots / slashes in the decoded name are all out of shape.
|
|
58
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/a.b/mcp`, BOUND)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns null for a malformed percent-escape in the vault segment (safeDecode catch path)", () => {
|
|
62
|
+
// `%GG` is not a valid percent-escape — `decodeURIComponent` throws; the
|
|
63
|
+
// helper must degrade to null rather than 500 the authorize handler.
|
|
64
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/%GG/mcp`, BOUND)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("narrowResourceVaultScopes", () => {
|
|
69
|
+
test("narrows unnamed vault verbs to the named form", () => {
|
|
70
|
+
expect(narrowResourceVaultScopes(["vault:read", "vault:write"], "jon")).toEqual([
|
|
71
|
+
"vault:jon:read",
|
|
72
|
+
"vault:jon:write",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("leaves already-named scopes for other vaults untouched", () => {
|
|
77
|
+
expect(narrowResourceVaultScopes(["vault:other:read"], "jon")).toEqual(["vault:other:read"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("passes non-vault scopes through unchanged", () => {
|
|
81
|
+
expect(narrowResourceVaultScopes(["scribe:transcribe", "vault:read"], "jon")).toEqual([
|
|
82
|
+
"scribe:transcribe",
|
|
83
|
+
"vault:jon:read",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("narrows the admin verb too (gate happens downstream)", () => {
|
|
88
|
+
// narrowResourceVaultScopes only rewrites shape; the non-requestable gate
|
|
89
|
+
// (`vault:<name>:admin`) blocks it afterward.
|
|
90
|
+
expect(narrowResourceVaultScopes(["vault:admin"], "jon")).toEqual(["vault:jon:admin"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("is idempotent over an already-narrowed list", () => {
|
|
94
|
+
const once = narrowResourceVaultScopes(["vault:read"], "jon");
|
|
95
|
+
expect(narrowResourceVaultScopes(once, "jon")).toEqual(once);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
SCOPE_EXPLANATIONS,
|
|
6
6
|
explainScope,
|
|
7
7
|
isRequestableScope,
|
|
8
|
+
isWellFormedOrNonVaultScope,
|
|
8
9
|
scopeIsAdmin,
|
|
9
10
|
} from "../scope-explanations.ts";
|
|
10
11
|
|
|
@@ -59,10 +60,18 @@ describe("explainScope", () => {
|
|
|
59
60
|
expect(explainScope("vault:*:write")?.level).toBe("write");
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// Single-consent change (2026-05-29): vault:<name>:admin is now REQUESTABLE
|
|
64
|
+
// and reaches the consent screen, so explainScope MUST resolve it to the
|
|
65
|
+
// vault:admin explanation (level "admin"). This is load-bearing: it makes
|
|
66
|
+
// scopeIsAdmin("vault:<name>:admin") return true, which the same-hub and
|
|
67
|
+
// trust-by-name auto-mint gates rely on to keep admin consent-gated.
|
|
68
|
+
test("resolves a per-vault admin (vault:<name>:admin) to the vault:admin explanation", () => {
|
|
69
|
+
expect(explainScope("vault:default:admin")?.label).toBe(
|
|
70
|
+
SCOPE_EXPLANATIONS["vault:admin"]?.label,
|
|
71
|
+
);
|
|
72
|
+
expect(explainScope("vault:default:admin")?.level).toBe("admin");
|
|
73
|
+
expect(explainScope("vault:my-techne_2:admin")?.level).toBe("admin");
|
|
74
|
+
expect(explainScope("vault:*:admin")?.level).toBe("admin");
|
|
66
75
|
});
|
|
67
76
|
});
|
|
68
77
|
|
|
@@ -73,6 +82,17 @@ describe("scopeIsAdmin", () => {
|
|
|
73
82
|
expect(scopeIsAdmin("parachute:host:admin")).toBe(true);
|
|
74
83
|
});
|
|
75
84
|
|
|
85
|
+
// Single-consent change (2026-05-29): the named per-vault admin form must
|
|
86
|
+
// be recognized as admin. LOAD-BEARING — the same-hub auto-trust gate
|
|
87
|
+
// (`!hasAdminScope`) and the trust-by-client_name gate
|
|
88
|
+
// (`!requestedScopes.some(scopeIsAdmin)`) rely on this to keep a named admin
|
|
89
|
+
// grant consent-gated instead of silently auto-minting it.
|
|
90
|
+
test("true for named per-vault admin (vault:<name>:admin)", () => {
|
|
91
|
+
expect(scopeIsAdmin("vault:work:admin")).toBe(true);
|
|
92
|
+
expect(scopeIsAdmin("vault:default:admin")).toBe(true);
|
|
93
|
+
expect(scopeIsAdmin("vault:my-techne_2:admin")).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
76
96
|
test("false for non-admin and unknown scopes", () => {
|
|
77
97
|
expect(scopeIsAdmin("vault:read")).toBe(false);
|
|
78
98
|
expect(scopeIsAdmin("channel:send")).toBe(false);
|
|
@@ -119,17 +139,62 @@ describe("isRequestableScope", () => {
|
|
|
119
139
|
expect(isRequestableScope("notes:something-new")).toBe(true);
|
|
120
140
|
});
|
|
121
141
|
|
|
122
|
-
//
|
|
123
|
-
// public OAuth flow
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
expect(isRequestableScope("vault:
|
|
142
|
+
// Single-consent change (2026-05-29): per-vault admin scopes are now
|
|
143
|
+
// requestable via the public OAuth flow. The anti-privesc cap at the mint
|
|
144
|
+
// choke-point (`capScopesToUserAuthority`) keeps a non-owner from actually
|
|
145
|
+
// being granted admin — but the scope is no longer rejected up front, so
|
|
146
|
+
// Claude MCP (consenting as the owner) can mint a vault admin token.
|
|
147
|
+
test("true for any vault:<name>:admin scope (single-consent change)", () => {
|
|
148
|
+
expect(isRequestableScope("vault:default:admin")).toBe(true);
|
|
149
|
+
expect(isRequestableScope("vault:work:admin")).toBe(true);
|
|
150
|
+
expect(isRequestableScope("vault:my-techne_2:admin")).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("host-level operator scopes stay non-requestable", () => {
|
|
154
|
+
// The asymmetry the single-consent change preserved: per-vault admin is
|
|
155
|
+
// now requestable (capped at mint), but host-wide operator authority is
|
|
156
|
+
// still operator-only-mintable.
|
|
157
|
+
expect(isRequestableScope("parachute:host:admin")).toBe(false);
|
|
158
|
+
expect(isRequestableScope("parachute:host:auth")).toBe(false);
|
|
129
159
|
});
|
|
130
160
|
|
|
131
|
-
test("vault:<name>:read|write stays requestable
|
|
161
|
+
test("vault:<name>:read|write stays requestable", () => {
|
|
132
162
|
expect(isRequestableScope("vault:default:read")).toBe(true);
|
|
133
163
|
expect(isRequestableScope("vault:work:write")).toBe(true);
|
|
134
164
|
});
|
|
135
165
|
});
|
|
166
|
+
|
|
167
|
+
// Mint-time shape guard (defensive hygiene, audit 2026-05-28). Rejects only the
|
|
168
|
+
// *named* three-segment vault shape when malformed; leaves the unnamed two-
|
|
169
|
+
// segment forms and all non-vault scopes alone.
|
|
170
|
+
describe("isWellFormedOrNonVaultScope", () => {
|
|
171
|
+
test("rejects the four audited malformed named-vault forms", () => {
|
|
172
|
+
expect(isWellFormedOrNonVaultScope("vault:work:ADMIN")).toBe(false); // uppercase verb
|
|
173
|
+
expect(isWellFormedOrNonVaultScope("vault::admin")).toBe(false); // empty name
|
|
174
|
+
expect(isWellFormedOrNonVaultScope("vault:work:read:admin")).toBe(false); // extra segment
|
|
175
|
+
expect(isWellFormedOrNonVaultScope("VAULT:work:admin")).toBe(false); // uppercase resource
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("admits well-formed named-vault scopes (all three verbs)", () => {
|
|
179
|
+
expect(isWellFormedOrNonVaultScope("vault:work:read")).toBe(true);
|
|
180
|
+
expect(isWellFormedOrNonVaultScope("vault:work:write")).toBe(true);
|
|
181
|
+
expect(isWellFormedOrNonVaultScope("vault:work:admin")).toBe(true);
|
|
182
|
+
expect(isWellFormedOrNonVaultScope("vault:my-techne_2:admin")).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("admits the unnamed two-segment vault forms (out of remit)", () => {
|
|
186
|
+
expect(isWellFormedOrNonVaultScope("vault:read")).toBe(true);
|
|
187
|
+
expect(isWellFormedOrNonVaultScope("vault:write")).toBe(true);
|
|
188
|
+
expect(isWellFormedOrNonVaultScope("vault:admin")).toBe(true);
|
|
189
|
+
expect(isWellFormedOrNonVaultScope("vault")).toBe(true); // bare, no colon
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("admits all non-vault scopes unconditionally", () => {
|
|
193
|
+
expect(isWellFormedOrNonVaultScope("scribe:transcribe")).toBe(true);
|
|
194
|
+
expect(isWellFormedOrNonVaultScope("parachute:host:auth")).toBe(true);
|
|
195
|
+
expect(isWellFormedOrNonVaultScope("parachute:host:admin")).toBe(true);
|
|
196
|
+
expect(isWellFormedOrNonVaultScope("hub:admin")).toBe(true);
|
|
197
|
+
// A three-segment non-vault scope is not constrained even if malformed-looking.
|
|
198
|
+
expect(isWellFormedOrNonVaultScope("scribe:work:ADMIN")).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
type ServiceEntry,
|
|
7
7
|
ServicesManifestError,
|
|
8
8
|
type UiSubUnit,
|
|
9
|
+
clearStartError,
|
|
9
10
|
findService,
|
|
10
11
|
readManifest,
|
|
11
12
|
readManifestLenient,
|
|
13
|
+
recordStartError,
|
|
12
14
|
removeService,
|
|
13
15
|
upsertService,
|
|
14
16
|
writeManifest,
|
|
@@ -1410,9 +1412,27 @@ describe("readManifestLenient — skips bad entries instead of throwing (hub#406
|
|
|
1410
1412
|
path,
|
|
1411
1413
|
JSON.stringify({
|
|
1412
1414
|
services: [
|
|
1413
|
-
{
|
|
1414
|
-
|
|
1415
|
-
|
|
1415
|
+
{
|
|
1416
|
+
name: "parachute-vault",
|
|
1417
|
+
port: 1940,
|
|
1418
|
+
paths: ["/vault/default"],
|
|
1419
|
+
health: "/vault/default/health",
|
|
1420
|
+
version: "0.4.8-rc.10",
|
|
1421
|
+
},
|
|
1422
|
+
{
|
|
1423
|
+
name: "parachute-surface",
|
|
1424
|
+
port: 1946,
|
|
1425
|
+
paths: ["/surface"],
|
|
1426
|
+
health: "/surface/healthz",
|
|
1427
|
+
version: "0.2.0-rc.13",
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
name: "widget",
|
|
1431
|
+
port: 0,
|
|
1432
|
+
paths: ["/widget"],
|
|
1433
|
+
health: "/widget/health",
|
|
1434
|
+
version: "0.0.1",
|
|
1435
|
+
},
|
|
1416
1436
|
],
|
|
1417
1437
|
}),
|
|
1418
1438
|
);
|
|
@@ -1479,7 +1499,15 @@ describe("readManifestLenient — skips bad entries instead of throwing (hub#406
|
|
|
1479
1499
|
writeFileSync(
|
|
1480
1500
|
path,
|
|
1481
1501
|
JSON.stringify({
|
|
1482
|
-
services: [
|
|
1502
|
+
services: [
|
|
1503
|
+
{
|
|
1504
|
+
name: "widget",
|
|
1505
|
+
port: 0,
|
|
1506
|
+
paths: ["/widget"],
|
|
1507
|
+
health: "/widget/health",
|
|
1508
|
+
version: "0.0.1",
|
|
1509
|
+
},
|
|
1510
|
+
],
|
|
1483
1511
|
}),
|
|
1484
1512
|
);
|
|
1485
1513
|
expect(() => readManifest(path)).toThrow(ServicesManifestError);
|
|
@@ -1487,4 +1515,94 @@ describe("readManifestLenient — skips bad entries instead of throwing (hub#406
|
|
|
1487
1515
|
cleanup();
|
|
1488
1516
|
}
|
|
1489
1517
|
});
|
|
1518
|
+
|
|
1519
|
+
describe("lastStartError", () => {
|
|
1520
|
+
const wire = {
|
|
1521
|
+
error_type: "missing_dependency",
|
|
1522
|
+
error_description: "parachute-vault is required ...",
|
|
1523
|
+
binary: "parachute-vault",
|
|
1524
|
+
why: "run the Vault module Hub supervises",
|
|
1525
|
+
docs_url: "https://parachute.computer",
|
|
1526
|
+
install: { generic: "parachute install vault" },
|
|
1527
|
+
sysadmin_hint: "Or ask your system administrator to install it for you.",
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
test("recordStartError persists the wire + stamps `at`", () => {
|
|
1531
|
+
const { path, cleanup } = makeTempPath();
|
|
1532
|
+
try {
|
|
1533
|
+
upsertService(vault, path);
|
|
1534
|
+
recordStartError("parachute-vault", wire, path);
|
|
1535
|
+
const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
|
|
1536
|
+
expect(entry?.lastStartError?.error_type).toBe("missing_dependency");
|
|
1537
|
+
expect(entry?.lastStartError?.binary).toBe("parachute-vault");
|
|
1538
|
+
expect(entry?.lastStartError?.install?.generic).toBe("parachute install vault");
|
|
1539
|
+
expect(entry?.lastStartError?.at).toBeDefined();
|
|
1540
|
+
} finally {
|
|
1541
|
+
cleanup();
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("recordStartError is a no-op when the row is absent", () => {
|
|
1546
|
+
const { path, cleanup } = makeTempPath();
|
|
1547
|
+
try {
|
|
1548
|
+
upsertService(vault, path);
|
|
1549
|
+
recordStartError("parachute-scribe", wire, path);
|
|
1550
|
+
const scribe = readManifest(path).services.find((s) => s.name === "parachute-scribe");
|
|
1551
|
+
expect(scribe).toBeUndefined();
|
|
1552
|
+
} finally {
|
|
1553
|
+
cleanup();
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
test("clearStartError removes a recorded error", () => {
|
|
1558
|
+
const { path, cleanup } = makeTempPath();
|
|
1559
|
+
try {
|
|
1560
|
+
upsertService(vault, path);
|
|
1561
|
+
recordStartError("parachute-vault", wire, path);
|
|
1562
|
+
clearStartError("parachute-vault", path);
|
|
1563
|
+
const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
|
|
1564
|
+
expect(entry?.lastStartError).toBeUndefined();
|
|
1565
|
+
} finally {
|
|
1566
|
+
cleanup();
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
test("lastStartError round-trips through validation", () => {
|
|
1571
|
+
const { path, cleanup } = makeTempPath();
|
|
1572
|
+
try {
|
|
1573
|
+
const withErr: ServiceEntry = {
|
|
1574
|
+
...vault,
|
|
1575
|
+
lastStartError: { ...wire, at: "2026-05-29T00:00:00Z" },
|
|
1576
|
+
};
|
|
1577
|
+
upsertService(withErr, path);
|
|
1578
|
+
const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
|
|
1579
|
+
expect(entry?.lastStartError).toEqual({ ...wire, at: "2026-05-29T00:00:00Z" });
|
|
1580
|
+
} finally {
|
|
1581
|
+
cleanup();
|
|
1582
|
+
}
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
test("a malformed lastStartError is dropped, not thrown (diagnostic field)", () => {
|
|
1586
|
+
const { path, cleanup } = makeTempPath();
|
|
1587
|
+
try {
|
|
1588
|
+
writeFileSync(
|
|
1589
|
+
path,
|
|
1590
|
+
JSON.stringify({
|
|
1591
|
+
services: [
|
|
1592
|
+
{
|
|
1593
|
+
...vault,
|
|
1594
|
+
// missing error_description → invalid shape → dropped
|
|
1595
|
+
lastStartError: { error_type: "missing_dependency" },
|
|
1596
|
+
},
|
|
1597
|
+
],
|
|
1598
|
+
}),
|
|
1599
|
+
);
|
|
1600
|
+
const entry = readManifest(path).services.find((s) => s.name === "parachute-vault");
|
|
1601
|
+
expect(entry).toBeDefined();
|
|
1602
|
+
expect(entry?.lastStartError).toBeUndefined();
|
|
1603
|
+
} finally {
|
|
1604
|
+
cleanup();
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1490
1608
|
});
|