@openparachute/hub 0.5.13 → 0.5.14-rc.10
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- 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 +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- 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 +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- 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 +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- 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 +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -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-Dzrbe6EP.js +0 -61
|
@@ -20,6 +20,7 @@ const PARAMS: AuthorizeFormParams = {
|
|
|
20
20
|
codeChallenge: "ch",
|
|
21
21
|
codeChallengeMethod: "S256",
|
|
22
22
|
state: "xyz",
|
|
23
|
+
resource: null,
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const CSRF = "csrf-token-fixture";
|
|
@@ -50,9 +51,19 @@ describe("renderHiddenInputs", () => {
|
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
test("escapes hostile values into hidden inputs", () => {
|
|
53
|
-
const html = renderHiddenInputs({
|
|
54
|
+
const html = renderHiddenInputs({
|
|
55
|
+
...PARAMS,
|
|
56
|
+
state: `"><script>alert(1)</script>`,
|
|
57
|
+
// RFC 8707 resource is round-tripped through a hidden input too, so a
|
|
58
|
+
// hostile value must be escaped the same way.
|
|
59
|
+
resource: `"><script>alert(2)</script>`,
|
|
60
|
+
});
|
|
54
61
|
expect(html).not.toContain("<script>alert(1)</script>");
|
|
62
|
+
expect(html).not.toContain("<script>alert(2)</script>");
|
|
55
63
|
expect(html).toContain("<script>");
|
|
64
|
+
// The resource value is emitted as a hidden input (escaped).
|
|
65
|
+
expect(html).toContain('name="resource"');
|
|
66
|
+
expect(html).toContain("alert(2)");
|
|
56
67
|
});
|
|
57
68
|
});
|
|
58
69
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_MODULES_URL,
|
|
4
|
+
ERROR_TYPE_PERSISTENT,
|
|
5
|
+
ERROR_TYPE_TRANSIENT,
|
|
6
|
+
TRANSIENT_MAX_ATTEMPTS,
|
|
7
|
+
TRANSIENT_RETRY_MS,
|
|
8
|
+
renderProxyError,
|
|
9
|
+
renderProxyErrorHtml,
|
|
10
|
+
renderProxyErrorJson,
|
|
11
|
+
statusForState,
|
|
12
|
+
toResponse,
|
|
13
|
+
wantsHtml,
|
|
14
|
+
} from "../proxy-error-ui.ts";
|
|
15
|
+
|
|
16
|
+
function req(accept?: string): Request {
|
|
17
|
+
const headers: Record<string, string> = {};
|
|
18
|
+
if (accept !== undefined) headers.accept = accept;
|
|
19
|
+
return new Request("http://127.0.0.1/vault/default/health", { headers });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("wantsHtml — Accept-header negotiation", () => {
|
|
23
|
+
test("text/html → HTML", () => {
|
|
24
|
+
expect(wantsHtml(req("text/html"))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
test("application/json → JSON", () => {
|
|
27
|
+
expect(wantsHtml(req("application/json"))).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
test("missing Accept → HTML (browser-ish default)", () => {
|
|
30
|
+
expect(wantsHtml(req())).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
test("*/* alone → HTML", () => {
|
|
33
|
+
expect(wantsHtml(req("*/*"))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
test("text/html,application/xhtml+xml,application/xml → HTML", () => {
|
|
36
|
+
expect(wantsHtml(req("text/html,application/xhtml+xml,application/xml"))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
test("application/json with text/html present → HTML", () => {
|
|
39
|
+
// If a client sends both, we lean HTML — matches the hub's existing
|
|
40
|
+
// one-line accept check at the 404 fallthrough.
|
|
41
|
+
expect(wantsHtml(req("application/json, text/html"))).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("statusForState", () => {
|
|
46
|
+
test("transient → 503", () => {
|
|
47
|
+
expect(statusForState("transient")).toBe(503);
|
|
48
|
+
});
|
|
49
|
+
test("persistent → 502", () => {
|
|
50
|
+
expect(statusForState("persistent")).toBe(502);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("renderProxyErrorJson", () => {
|
|
55
|
+
test("transient → 503 JSON with retry_after_ms + max_attempts + no admin_url", () => {
|
|
56
|
+
const out = renderProxyErrorJson({
|
|
57
|
+
short: "vault",
|
|
58
|
+
serviceLabel: "parachute-vault",
|
|
59
|
+
state: "transient",
|
|
60
|
+
upstreamError: "ECONNREFUSED",
|
|
61
|
+
});
|
|
62
|
+
expect(out.status).toBe(503);
|
|
63
|
+
expect(out.contentType).toBe("application/json");
|
|
64
|
+
expect(out.retryAfter).toBe("2");
|
|
65
|
+
const body = JSON.parse(out.body) as {
|
|
66
|
+
error: string;
|
|
67
|
+
error_type: string;
|
|
68
|
+
retry_after_ms: number;
|
|
69
|
+
max_attempts: number;
|
|
70
|
+
admin_url?: string;
|
|
71
|
+
service: string;
|
|
72
|
+
};
|
|
73
|
+
expect(body.error).toBe(ERROR_TYPE_TRANSIENT);
|
|
74
|
+
expect(body.error_type).toBe(ERROR_TYPE_TRANSIENT);
|
|
75
|
+
expect(body.retry_after_ms).toBe(TRANSIENT_RETRY_MS);
|
|
76
|
+
expect(body.max_attempts).toBe(TRANSIENT_MAX_ATTEMPTS);
|
|
77
|
+
expect(body.admin_url).toBeUndefined();
|
|
78
|
+
expect(body.service).toBe("vault");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("persistent → 502 JSON with admin_url + no retry_after_ms", () => {
|
|
82
|
+
const out = renderProxyErrorJson({
|
|
83
|
+
short: "scribe",
|
|
84
|
+
serviceLabel: "scribe",
|
|
85
|
+
state: "persistent",
|
|
86
|
+
upstreamError: "ECONNREFUSED",
|
|
87
|
+
});
|
|
88
|
+
expect(out.status).toBe(502);
|
|
89
|
+
expect(out.contentType).toBe("application/json");
|
|
90
|
+
expect(out.retryAfter).toBeUndefined();
|
|
91
|
+
const body = JSON.parse(out.body) as {
|
|
92
|
+
error: string;
|
|
93
|
+
error_type: string;
|
|
94
|
+
retry_after_ms?: number;
|
|
95
|
+
admin_url?: string;
|
|
96
|
+
};
|
|
97
|
+
expect(body.error).toBe(ERROR_TYPE_PERSISTENT);
|
|
98
|
+
expect(body.error_type).toBe(ERROR_TYPE_PERSISTENT);
|
|
99
|
+
expect(body.admin_url).toBe(ADMIN_MODULES_URL);
|
|
100
|
+
expect(body.retry_after_ms).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("persistent JSON folds upstreamError into error_description", () => {
|
|
104
|
+
const out = renderProxyErrorJson({
|
|
105
|
+
short: "vault",
|
|
106
|
+
serviceLabel: "parachute-vault",
|
|
107
|
+
state: "persistent",
|
|
108
|
+
upstreamError: "ECONNREFUSED 127.0.0.1:1940",
|
|
109
|
+
});
|
|
110
|
+
const body = JSON.parse(out.body) as { error_description: string };
|
|
111
|
+
expect(body.error_description).toContain("ECONNREFUSED");
|
|
112
|
+
expect(body.error_description).toContain("parachute-vault");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("renderProxyErrorHtml", () => {
|
|
117
|
+
test("transient HTML → 503 + meta-refresh + Retry-After + poll script", () => {
|
|
118
|
+
const out = renderProxyErrorHtml({
|
|
119
|
+
short: "vault",
|
|
120
|
+
serviceLabel: "parachute-vault",
|
|
121
|
+
state: "transient",
|
|
122
|
+
upstreamError: "ECONNREFUSED",
|
|
123
|
+
});
|
|
124
|
+
expect(out.status).toBe(503);
|
|
125
|
+
expect(out.contentType).toBe("text/html; charset=utf-8");
|
|
126
|
+
expect(out.retryAfter).toBe("2");
|
|
127
|
+
expect(out.body).toContain(`<meta http-equiv="refresh" content="2">`);
|
|
128
|
+
expect(out.body).toContain("Just a moment");
|
|
129
|
+
expect(out.body).toContain("/api/ready");
|
|
130
|
+
expect(out.body).toContain("ready_modules");
|
|
131
|
+
expect(out.body).toContain(`maxAttempts = ${TRANSIENT_MAX_ATTEMPTS}`);
|
|
132
|
+
// Transient page MUST NOT include an admin link (Aaron design (d)).
|
|
133
|
+
expect(out.body).not.toContain("/admin/modules");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("persistent HTML → 502 + no meta-refresh + admin link + manual refresh", () => {
|
|
137
|
+
const out = renderProxyErrorHtml({
|
|
138
|
+
short: "vault",
|
|
139
|
+
serviceLabel: "parachute-vault",
|
|
140
|
+
state: "persistent",
|
|
141
|
+
upstreamError: "ECONNREFUSED",
|
|
142
|
+
});
|
|
143
|
+
expect(out.status).toBe(502);
|
|
144
|
+
expect(out.contentType).toBe("text/html; charset=utf-8");
|
|
145
|
+
expect(out.retryAfter).toBeUndefined();
|
|
146
|
+
expect(out.body).not.toContain(`http-equiv="refresh"`);
|
|
147
|
+
expect(out.body).toContain("Module unreachable");
|
|
148
|
+
expect(out.body).toContain("/admin/modules");
|
|
149
|
+
expect(out.body).toContain("View module status");
|
|
150
|
+
// No periodic poll on the persistent page — only the manual-refresh
|
|
151
|
+
// listener. Assert by checking that maxAttempts/intervalMs constants
|
|
152
|
+
// aren't emitted into the script.
|
|
153
|
+
expect(out.body).not.toContain("maxAttempts");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("HTML escapes the service short name into the body", () => {
|
|
157
|
+
// Constructing a malicious short shouldn't break the page. ServiceEntry
|
|
158
|
+
// names are validated upstream but the renderer is defense-in-depth.
|
|
159
|
+
const out = renderProxyErrorHtml({
|
|
160
|
+
short: "<script>alert(1)</script>",
|
|
161
|
+
serviceLabel: "<malicious>",
|
|
162
|
+
state: "persistent",
|
|
163
|
+
upstreamError: "x",
|
|
164
|
+
});
|
|
165
|
+
expect(out.body).not.toContain("<script>alert(1)</script>");
|
|
166
|
+
expect(out.body).toContain("<script>");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("renderProxyError — Accept-driven dispatch", () => {
|
|
171
|
+
test("JSON-accepting request → JSON renderer", () => {
|
|
172
|
+
const out = renderProxyError(req("application/json"), {
|
|
173
|
+
short: "vault",
|
|
174
|
+
serviceLabel: "parachute-vault",
|
|
175
|
+
state: "transient",
|
|
176
|
+
upstreamError: "x",
|
|
177
|
+
});
|
|
178
|
+
expect(out.contentType).toBe("application/json");
|
|
179
|
+
});
|
|
180
|
+
test("HTML-accepting request → HTML renderer", () => {
|
|
181
|
+
const out = renderProxyError(req("text/html"), {
|
|
182
|
+
short: "vault",
|
|
183
|
+
serviceLabel: "parachute-vault",
|
|
184
|
+
state: "transient",
|
|
185
|
+
upstreamError: "x",
|
|
186
|
+
});
|
|
187
|
+
expect(out.contentType).toBe("text/html; charset=utf-8");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("toResponse", () => {
|
|
192
|
+
test("attaches Retry-After when present", () => {
|
|
193
|
+
const out = toResponse({
|
|
194
|
+
body: "{}",
|
|
195
|
+
status: 503,
|
|
196
|
+
contentType: "application/json",
|
|
197
|
+
retryAfter: "2",
|
|
198
|
+
});
|
|
199
|
+
expect(out.status).toBe(503);
|
|
200
|
+
expect(out.headers.get("retry-after")).toBe("2");
|
|
201
|
+
expect(out.headers.get("content-type")).toBe("application/json");
|
|
202
|
+
expect(out.headers.get("cache-control")).toBe("no-store");
|
|
203
|
+
});
|
|
204
|
+
test("omits Retry-After when absent", () => {
|
|
205
|
+
const out = toResponse({
|
|
206
|
+
body: "{}",
|
|
207
|
+
status: 502,
|
|
208
|
+
contentType: "application/json",
|
|
209
|
+
});
|
|
210
|
+
expect(out.headers.get("retry-after")).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -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
|
|
|
@@ -133,3 +134,38 @@ describe("isRequestableScope", () => {
|
|
|
133
134
|
expect(isRequestableScope("vault:work:write")).toBe(true);
|
|
134
135
|
});
|
|
135
136
|
});
|
|
137
|
+
|
|
138
|
+
// Mint-time shape guard (defensive hygiene, audit 2026-05-28). Rejects only the
|
|
139
|
+
// *named* three-segment vault shape when malformed; leaves the unnamed two-
|
|
140
|
+
// segment forms and all non-vault scopes alone.
|
|
141
|
+
describe("isWellFormedOrNonVaultScope", () => {
|
|
142
|
+
test("rejects the four audited malformed named-vault forms", () => {
|
|
143
|
+
expect(isWellFormedOrNonVaultScope("vault:work:ADMIN")).toBe(false); // uppercase verb
|
|
144
|
+
expect(isWellFormedOrNonVaultScope("vault::admin")).toBe(false); // empty name
|
|
145
|
+
expect(isWellFormedOrNonVaultScope("vault:work:read:admin")).toBe(false); // extra segment
|
|
146
|
+
expect(isWellFormedOrNonVaultScope("VAULT:work:admin")).toBe(false); // uppercase resource
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("admits well-formed named-vault scopes (all three verbs)", () => {
|
|
150
|
+
expect(isWellFormedOrNonVaultScope("vault:work:read")).toBe(true);
|
|
151
|
+
expect(isWellFormedOrNonVaultScope("vault:work:write")).toBe(true);
|
|
152
|
+
expect(isWellFormedOrNonVaultScope("vault:work:admin")).toBe(true);
|
|
153
|
+
expect(isWellFormedOrNonVaultScope("vault:my-techne_2:admin")).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("admits the unnamed two-segment vault forms (out of remit)", () => {
|
|
157
|
+
expect(isWellFormedOrNonVaultScope("vault:read")).toBe(true);
|
|
158
|
+
expect(isWellFormedOrNonVaultScope("vault:write")).toBe(true);
|
|
159
|
+
expect(isWellFormedOrNonVaultScope("vault:admin")).toBe(true);
|
|
160
|
+
expect(isWellFormedOrNonVaultScope("vault")).toBe(true); // bare, no colon
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("admits all non-vault scopes unconditionally", () => {
|
|
164
|
+
expect(isWellFormedOrNonVaultScope("scribe:transcribe")).toBe(true);
|
|
165
|
+
expect(isWellFormedOrNonVaultScope("parachute:host:auth")).toBe(true);
|
|
166
|
+
expect(isWellFormedOrNonVaultScope("parachute:host:admin")).toBe(true);
|
|
167
|
+
expect(isWellFormedOrNonVaultScope("hub:admin")).toBe(true);
|
|
168
|
+
// A three-segment non-vault scope is not constrained even if malformed-looking.
|
|
169
|
+
expect(isWellFormedOrNonVaultScope("scribe:work:ADMIN")).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -52,11 +52,11 @@ describe("seedInitialAdminIfNeeded", () => {
|
|
|
52
52
|
expect(log.mock.calls[0]?.[0] ?? "").toContain("ops");
|
|
53
53
|
// Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md §wizard
|
|
54
54
|
// interaction): env-seeded admin chose their password via env vars, so
|
|
55
|
-
// skip the force-change-password redirect. `
|
|
56
|
-
// — admin posture.
|
|
55
|
+
// skip the force-change-password redirect. `assignedVaults` stays []
|
|
56
|
+
// — admin posture (multi-user Phase 2 PR 2 lifted single → array).
|
|
57
57
|
const seeded = getUserByUsername(db, "ops");
|
|
58
58
|
expect(seeded?.passwordChanged).toBe(true);
|
|
59
|
-
expect(seeded?.
|
|
59
|
+
expect(seeded?.assignedVaults).toEqual([]);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("returns 'exists' when an admin already exists, even with env vars set", async () => {
|
|
@@ -287,12 +287,12 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
287
287
|
test("strips trailing slashes from any source for canonical form", () => {
|
|
288
288
|
expect(resolveStartupIssuer({ issuer: "https://x.example/" }, {})).toBe("https://x.example");
|
|
289
289
|
expect(resolveStartupIssuer({ issuer: "https://x.example//" }, {})).toBe("https://x.example");
|
|
290
|
-
expect(
|
|
291
|
-
|
|
292
|
-
)
|
|
293
|
-
expect(
|
|
294
|
-
|
|
295
|
-
)
|
|
290
|
+
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "https://x.example/" })).toBe(
|
|
291
|
+
"https://x.example",
|
|
292
|
+
);
|
|
293
|
+
expect(resolveStartupIssuer({}, { RENDER_EXTERNAL_URL: "https://x.example/" })).toBe(
|
|
294
|
+
"https://x.example",
|
|
295
|
+
);
|
|
296
296
|
});
|
|
297
297
|
|
|
298
298
|
test("returns undefined when no source has a value", () => {
|