@openparachute/hub 0.7.1 → 0.7.2
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 +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the in-flight OAuth consent-flow store (`oauth-flows-store.ts`, 4b-2).
|
|
3
|
+
*
|
|
4
|
+
* put / get-by-state / delete-on-use / round-trip / TTL prune / 0600 perms / the
|
|
5
|
+
* lenient-read posture. The file holds PKCE verifiers (secrets) → 0600.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
FLOW_TTL_MS,
|
|
13
|
+
type PendingFlow,
|
|
14
|
+
deleteFlow,
|
|
15
|
+
getFlowByState,
|
|
16
|
+
pruneExpiredFlows,
|
|
17
|
+
putFlow,
|
|
18
|
+
} from "../oauth-flows-store.ts";
|
|
19
|
+
|
|
20
|
+
let dir: string;
|
|
21
|
+
let storePath: string;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
dir = mkdtempSync(join(tmpdir(), "phub-oauth-flows-"));
|
|
24
|
+
storePath = join(dir, "agent-oauth-flows.json");
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function flow(over: Partial<PendingFlow> = {}): PendingFlow {
|
|
31
|
+
return {
|
|
32
|
+
state: over.state ?? "state-1",
|
|
33
|
+
grantId: over.grantId ?? "grant-1",
|
|
34
|
+
issuer: over.issuer ?? "https://issuer.test",
|
|
35
|
+
clientId: over.clientId ?? "cid-1",
|
|
36
|
+
tokenEndpoint: over.tokenEndpoint ?? "https://issuer.test/oauth/token",
|
|
37
|
+
verifier: over.verifier ?? "pkce-verifier-secret",
|
|
38
|
+
mcpUrl: over.mcpUrl ?? "https://remote.test/mcp",
|
|
39
|
+
redirectUri: over.redirectUri ?? "https://hub.test/oauth/agent-grant/callback",
|
|
40
|
+
createdAt: over.createdAt ?? new Date().toISOString(),
|
|
41
|
+
...(over.revocationEndpoint ? { revocationEndpoint: over.revocationEndpoint } : {}),
|
|
42
|
+
...(over.scope ? { scope: over.scope } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("round-trip", () => {
|
|
47
|
+
test("a missing file returns null for any state", () => {
|
|
48
|
+
expect(getFlowByState(storePath, "anything")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("put → get by state", () => {
|
|
52
|
+
const f = flow();
|
|
53
|
+
putFlow(storePath, f);
|
|
54
|
+
const back = getFlowByState(storePath, "state-1");
|
|
55
|
+
expect(back).toEqual(f);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("put upserts by state (no duplicate)", () => {
|
|
59
|
+
putFlow(storePath, flow({ clientId: "old" }));
|
|
60
|
+
putFlow(storePath, flow({ clientId: "new" }));
|
|
61
|
+
expect(getFlowByState(storePath, "state-1")?.clientId).toBe("new");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("delete-on-use removes the flow (single-use)", () => {
|
|
65
|
+
putFlow(storePath, flow());
|
|
66
|
+
const removed = deleteFlow(storePath, "state-1");
|
|
67
|
+
expect(removed?.state).toBe("state-1");
|
|
68
|
+
expect(getFlowByState(storePath, "state-1")).toBeNull();
|
|
69
|
+
// second delete is a no-op
|
|
70
|
+
expect(deleteFlow(storePath, "state-1")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("distinct states coexist", () => {
|
|
74
|
+
putFlow(storePath, flow({ state: "a" }));
|
|
75
|
+
putFlow(storePath, flow({ state: "b", grantId: "grant-2" }));
|
|
76
|
+
expect(getFlowByState(storePath, "a")?.grantId).toBe("grant-1");
|
|
77
|
+
expect(getFlowByState(storePath, "b")?.grantId).toBe("grant-2");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("TTL prune", () => {
|
|
82
|
+
test("an expired flow is pruned on read (returns null)", () => {
|
|
83
|
+
const old = new Date(Date.now() - (FLOW_TTL_MS + 60_000)).toISOString();
|
|
84
|
+
putFlow(storePath, flow({ createdAt: old }));
|
|
85
|
+
// The default-now read prunes it.
|
|
86
|
+
expect(getFlowByState(storePath, "state-1")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("pruneExpiredFlows drops the expired and keeps the live", () => {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
putFlow(storePath, flow({ state: "fresh", createdAt: new Date(now).toISOString() }), now);
|
|
92
|
+
putFlow(
|
|
93
|
+
storePath,
|
|
94
|
+
flow({ state: "stale", createdAt: new Date(now - FLOW_TTL_MS - 1).toISOString() }),
|
|
95
|
+
now,
|
|
96
|
+
);
|
|
97
|
+
const live = pruneExpiredFlows(storePath, now);
|
|
98
|
+
expect(live.map((f) => f.state).sort()).toEqual(["fresh"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("put prunes expired flows before persisting", () => {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
// Seed an expired flow directly.
|
|
104
|
+
writeFileSync(
|
|
105
|
+
storePath,
|
|
106
|
+
JSON.stringify({
|
|
107
|
+
flows: [flow({ state: "stale", createdAt: new Date(now - FLOW_TTL_MS - 1).toISOString() })],
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
putFlow(storePath, flow({ state: "fresh", createdAt: new Date(now).toISOString() }), now);
|
|
111
|
+
expect(getFlowByState(storePath, "stale", now)).toBeNull();
|
|
112
|
+
expect(getFlowByState(storePath, "fresh", now)).not.toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("0600 perms (holds PKCE verifiers)", () => {
|
|
117
|
+
test("the store file is created mode 0600", () => {
|
|
118
|
+
putFlow(storePath, flow());
|
|
119
|
+
const mode = statSync(storePath).mode & 0o777;
|
|
120
|
+
expect(mode).toBe(0o600);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("lenient read", () => {
|
|
125
|
+
test("garbage JSON reads as empty", () => {
|
|
126
|
+
writeFileSync(storePath, "not json {{{");
|
|
127
|
+
expect(getFlowByState(storePath, "x")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a malformed row is dropped, valid rows survive", () => {
|
|
131
|
+
const valid = flow({ state: "ok" });
|
|
132
|
+
writeFileSync(
|
|
133
|
+
storePath,
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
flows: [valid, { state: "bad" /* missing required fields */ }],
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
expect(getFlowByState(storePath, "ok")).not.toBeNull();
|
|
139
|
+
expect(getFlowByState(storePath, "bad")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -115,9 +115,9 @@ describe("authorizationServerMetadata", () => {
|
|
|
115
115
|
expect(scopesSupported).toContain("vault:admin");
|
|
116
116
|
expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
|
|
117
117
|
expect(scopesSupported).toContain("hub:admin");
|
|
118
|
-
//
|
|
118
|
+
// agent isn't in the fixture manifest → its scopes aren't advertised
|
|
119
119
|
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
120
|
-
expect(scopesSupported).not.toContain("
|
|
120
|
+
expect(scopesSupported).not.toContain("agent:send");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
test("does NOT advertise non-requestable operator-only scopes", async () => {
|
|
@@ -145,8 +145,8 @@ describe("authorizationServerMetadata", () => {
|
|
|
145
145
|
"vault:admin",
|
|
146
146
|
"hub:admin",
|
|
147
147
|
"parachute:host:admin", // declared but operator-only — must still be filtered
|
|
148
|
-
"
|
|
149
|
-
"
|
|
148
|
+
"widget:read",
|
|
149
|
+
"widget:write",
|
|
150
150
|
"mymodule:do-thing",
|
|
151
151
|
]);
|
|
152
152
|
const res = authorizationServerMetadata({
|
|
@@ -156,9 +156,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
156
156
|
});
|
|
157
157
|
const body = (await res.json()) as Record<string, unknown>;
|
|
158
158
|
const scopesSupported = body.scopes_supported as string[];
|
|
159
|
-
// Third-party scopes show up
|
|
160
|
-
|
|
161
|
-
expect(scopesSupported).toContain("
|
|
159
|
+
// Third-party scopes show up (`widget:*` / `mymodule:*` aren't gated
|
|
160
|
+
// optional-module prefixes — only scribe:/agent: are, see OPTIONAL_MODULE_SCOPES).
|
|
161
|
+
expect(scopesSupported).toContain("widget:read");
|
|
162
|
+
expect(scopesSupported).toContain("widget:write");
|
|
162
163
|
expect(scopesSupported).toContain("mymodule:do-thing");
|
|
163
164
|
// First-party still advertised — no regression
|
|
164
165
|
expect(scopesSupported).toContain("vault:read");
|
|
@@ -169,10 +170,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
169
170
|
});
|
|
170
171
|
|
|
171
172
|
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
172
|
-
// FIRST_PARTY_SCOPES carries scribe:* +
|
|
173
|
+
// FIRST_PARTY_SCOPES carries scribe:* + agent:send statically. On a
|
|
173
174
|
// vault-only hub they must NOT be advertised — a discovery client (e.g.
|
|
174
175
|
// claude.ai's connector UI) lists the catalog verbatim, so a friend
|
|
175
|
-
// connecting one vault was shown Scribe +
|
|
176
|
+
// connecting one vault was shown Scribe + Agent access the hub can't
|
|
176
177
|
// honor. Vault + hub are core and always advertised.
|
|
177
178
|
const declared = new Set<string>([
|
|
178
179
|
"vault:read",
|
|
@@ -180,7 +181,7 @@ describe("authorizationServerMetadata", () => {
|
|
|
180
181
|
"vault:admin",
|
|
181
182
|
"scribe:transcribe",
|
|
182
183
|
"scribe:admin",
|
|
183
|
-
"
|
|
184
|
+
"agent:send",
|
|
184
185
|
"hub:admin",
|
|
185
186
|
]);
|
|
186
187
|
const vaultOnly = {
|
|
@@ -207,7 +208,7 @@ describe("authorizationServerMetadata", () => {
|
|
|
207
208
|
// uninstalled optional-module scopes are dropped
|
|
208
209
|
expect(scopes).not.toContain("scribe:transcribe");
|
|
209
210
|
expect(scopes).not.toContain("scribe:admin");
|
|
210
|
-
expect(scopes).not.toContain("
|
|
211
|
+
expect(scopes).not.toContain("agent:send");
|
|
211
212
|
|
|
212
213
|
// ...but once scribe is installed, its scopes ARE advertised again.
|
|
213
214
|
const withScribe = {
|
|
@@ -235,7 +236,7 @@ describe("authorizationServerMetadata", () => {
|
|
|
235
236
|
});
|
|
236
237
|
const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
237
238
|
expect(scopes2).toContain("scribe:transcribe");
|
|
238
|
-
expect(scopes2).not.toContain("
|
|
239
|
+
expect(scopes2).not.toContain("agent:send"); // agent still not installed
|
|
239
240
|
});
|
|
240
241
|
});
|
|
241
242
|
|
|
@@ -263,7 +264,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
|
263
264
|
"vault:admin",
|
|
264
265
|
"hub:admin",
|
|
265
266
|
"parachute:host:admin",
|
|
266
|
-
"
|
|
267
|
+
"widget:read",
|
|
267
268
|
]);
|
|
268
269
|
const res = protectedResourceMetadata({
|
|
269
270
|
issuer: ISSUER,
|
|
@@ -273,7 +274,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
|
273
274
|
const body = (await res.json()) as Record<string, unknown>;
|
|
274
275
|
const scopes = body.scopes_supported as string[];
|
|
275
276
|
expect(scopes).toContain("vault:read");
|
|
276
|
-
expect(scopes).toContain("
|
|
277
|
+
expect(scopes).toContain("widget:read");
|
|
277
278
|
expect(scopes).not.toContain("parachute:host:admin");
|
|
278
279
|
});
|
|
279
280
|
});
|
|
@@ -988,13 +989,13 @@ describe("handleAuthorizeGet — vault picker", () => {
|
|
|
988
989
|
describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes (scary-consent fix)", () => {
|
|
989
990
|
// claude.ai connecting to ONE vault reads the hub's whole-hub AS-metadata
|
|
990
991
|
// `scopes_supported` and over-requests the full catalog. Bound to the vault
|
|
991
|
-
// resource (`aud=vault.<name>`), scribe/
|
|
992
|
+
// resource (`aud=vault.<name>`), scribe/agent/hub scopes are unusable, so
|
|
992
993
|
// they must be DROPPED before consent — Aaron hit them as "a fuck ton of
|
|
993
994
|
// privileges that don't make sense" (scribe isn't even installed here).
|
|
994
995
|
const FOREIGN_AND_VAULT =
|
|
995
|
-
"vault:read vault:write scribe:transcribe scribe:admin
|
|
996
|
+
"vault:read vault:write scribe:transcribe scribe:admin agent:send hub:admin";
|
|
996
997
|
|
|
997
|
-
test("session consent for a vault MCP resource drops scribe/
|
|
998
|
+
test("session consent for a vault MCP resource drops scribe/agent/hub scopes", async () => {
|
|
998
999
|
const { db, cleanup } = await makeDb();
|
|
999
1000
|
try {
|
|
1000
1001
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -1035,7 +1036,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
|
|
|
1035
1036
|
// The foreign scopes are gone.
|
|
1036
1037
|
expect(html).not.toContain("Send audio to Scribe for transcription."); // scribe:transcribe
|
|
1037
1038
|
expect(html).not.toContain("Manage Scribe configuration"); // scribe:admin
|
|
1038
|
-
expect(html).not.toContain("Post messages to your
|
|
1039
|
+
expect(html).not.toContain("Post messages to your Agent."); // agent:send
|
|
1039
1040
|
expect(html).not.toContain("Manage hub identity"); // hub:admin
|
|
1040
1041
|
} finally {
|
|
1041
1042
|
cleanup();
|
|
@@ -1058,7 +1059,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
|
|
|
1058
1059
|
response_type: "code",
|
|
1059
1060
|
code_challenge: challenge,
|
|
1060
1061
|
code_challenge_method: "S256",
|
|
1061
|
-
scope: "vault:read scribe:transcribe
|
|
1062
|
+
scope: "vault:read scribe:transcribe agent:send",
|
|
1062
1063
|
resource: `${ISSUER}/vault/default/mcp`,
|
|
1063
1064
|
}),
|
|
1064
1065
|
// No session cookie → the unauth pending page renders.
|
|
@@ -1072,13 +1073,13 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
|
|
|
1072
1073
|
expect(html).toContain("App not yet approved");
|
|
1073
1074
|
// Foreign scopes absent from the rendered rows...
|
|
1074
1075
|
expect(html).not.toContain("Send audio to Scribe for transcription.");
|
|
1075
|
-
expect(html).not.toContain("Post messages to your
|
|
1076
|
+
expect(html).not.toContain("Post messages to your Agent.");
|
|
1076
1077
|
// ...and from the login round-trip URL embedded in the page (the
|
|
1077
1078
|
// narrowed scope was written back onto `url` before this render).
|
|
1078
1079
|
expect(html).not.toContain("scribe:transcribe");
|
|
1079
|
-
expect(html).not.toContain("
|
|
1080
|
+
expect(html).not.toContain("agent:send");
|
|
1080
1081
|
expect(html).not.toContain("scribe%3Atranscribe");
|
|
1081
|
-
expect(html).not.toContain("
|
|
1082
|
+
expect(html).not.toContain("agent%3Asend");
|
|
1082
1083
|
} finally {
|
|
1083
1084
|
cleanup();
|
|
1084
1085
|
}
|
|
@@ -1105,7 +1106,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
|
|
|
1105
1106
|
response_type: "code",
|
|
1106
1107
|
code_challenge: challenge,
|
|
1107
1108
|
code_challenge_method: "S256",
|
|
1108
|
-
scope: "scribe:transcribe
|
|
1109
|
+
scope: "scribe:transcribe agent:send",
|
|
1109
1110
|
resource: `${ISSUER}/vault/default/mcp`,
|
|
1110
1111
|
}),
|
|
1111
1112
|
{
|
|
@@ -1121,7 +1122,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
|
|
|
1121
1122
|
expect(res.status).toBe(200);
|
|
1122
1123
|
const html = await res.text();
|
|
1123
1124
|
expect(html).not.toContain("Send audio to Scribe for transcription.");
|
|
1124
|
-
expect(html).not.toContain("Post messages to your
|
|
1125
|
+
expect(html).not.toContain("Post messages to your Agent.");
|
|
1125
1126
|
} finally {
|
|
1126
1127
|
cleanup();
|
|
1127
1128
|
}
|
|
@@ -1130,7 +1131,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
|
|
|
1130
1131
|
test("trust-by-client_name no longer re-prompts when the request over-asks the whole-hub catalog", async () => {
|
|
1131
1132
|
// Before the narrowing was moved ahead of the status branch, the
|
|
1132
1133
|
// trust-by-client_name coverage check compared the RAW request
|
|
1133
|
-
// (`vault:read scribe:transcribe
|
|
1134
|
+
// (`vault:read scribe:transcribe agent:send`) against a vault-only prior
|
|
1134
1135
|
// grant — never matched — re-prompting consent every session for a client
|
|
1135
1136
|
// the operator had already approved. Narrowing first makes the comparison
|
|
1136
1137
|
// vault-only-vs-vault-only, so the silent re-link fires.
|
|
@@ -1160,7 +1161,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
|
|
|
1160
1161
|
response_type: "code",
|
|
1161
1162
|
code_challenge: challenge,
|
|
1162
1163
|
code_challenge_method: "S256",
|
|
1163
|
-
scope: "vault:read scribe:transcribe
|
|
1164
|
+
scope: "vault:read scribe:transcribe agent:send",
|
|
1164
1165
|
resource: `${ISSUER}/vault/default/mcp`,
|
|
1165
1166
|
}),
|
|
1166
1167
|
{
|
|
@@ -3279,6 +3280,103 @@ describe("handleRegister — RFC 7591 DCR", () => {
|
|
|
3279
3280
|
});
|
|
3280
3281
|
});
|
|
3281
3282
|
|
|
3283
|
+
// surface#118 — a hub-served module (surface, notes) registers at install time
|
|
3284
|
+
// knowing only the loopback hub origin; once exposed, the browser computes its
|
|
3285
|
+
// redirect_uri from the PUBLIC hub origin, which strict authorize-time matching
|
|
3286
|
+
// would reject. handleRegister expands hub-origin-rooted redirect_uris onto
|
|
3287
|
+
// every known hub origin (via deps.hubBoundOrigins). Foreign-origin URIs stay
|
|
3288
|
+
// verbatim — never expanded onto hub origins, never dropped (open-redirect
|
|
3289
|
+
// guard). Authorize-time matching is unchanged (strict exact-match).
|
|
3290
|
+
describe("handleRegister — cross-hub-origin redirect_uri expansion (surface#118)", () => {
|
|
3291
|
+
const PUBLIC = "https://box.taildf9ce2.ts.net";
|
|
3292
|
+
const LOOPBACK = "http://127.0.0.1:1939";
|
|
3293
|
+
const boundOrigins = () => [ISSUER, LOOPBACK, "http://localhost:1939", PUBLIC];
|
|
3294
|
+
|
|
3295
|
+
test("loopback-rooted URI is stored WITH the public-origin variant", async () => {
|
|
3296
|
+
const { db, cleanup } = await makeDb();
|
|
3297
|
+
try {
|
|
3298
|
+
const req = new Request(`${ISSUER}/oauth/register`, {
|
|
3299
|
+
method: "POST",
|
|
3300
|
+
body: JSON.stringify({
|
|
3301
|
+
redirect_uris: [`${LOOPBACK}/surface/notes/oauth/callback`],
|
|
3302
|
+
scope: "vault:default:read",
|
|
3303
|
+
client_name: "Notes",
|
|
3304
|
+
}),
|
|
3305
|
+
headers: { "content-type": "application/json" },
|
|
3306
|
+
});
|
|
3307
|
+
const res = await handleRegister(db, req, {
|
|
3308
|
+
issuer: ISSUER,
|
|
3309
|
+
hubBoundOrigins: boundOrigins,
|
|
3310
|
+
});
|
|
3311
|
+
expect(res.status).toBe(201);
|
|
3312
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
3313
|
+
const stored = body.redirect_uris as string[];
|
|
3314
|
+
// The submitted loopback URI is preserved...
|
|
3315
|
+
expect(stored).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
3316
|
+
// ...and the public-origin variant is now registered — the fix.
|
|
3317
|
+
expect(stored).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
3318
|
+
|
|
3319
|
+
// The stored set drives authorize-time matching; confirm the public
|
|
3320
|
+
// variant now matches via the real client record.
|
|
3321
|
+
const stored2 = getClient(db, body.client_id as string);
|
|
3322
|
+
expect(stored2?.redirectUris).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
3323
|
+
} finally {
|
|
3324
|
+
cleanup();
|
|
3325
|
+
}
|
|
3326
|
+
});
|
|
3327
|
+
|
|
3328
|
+
test("INVARIANT: a foreign-origin redirect_uri is stored verbatim, NOT expanded onto hub origins", async () => {
|
|
3329
|
+
const { db, cleanup } = await makeDb();
|
|
3330
|
+
try {
|
|
3331
|
+
const foreign = "https://my-vault-ui.example/oauth/callback";
|
|
3332
|
+
const req = new Request(`${ISSUER}/oauth/register`, {
|
|
3333
|
+
method: "POST",
|
|
3334
|
+
body: JSON.stringify({
|
|
3335
|
+
redirect_uris: [foreign],
|
|
3336
|
+
scope: "vault:default:read",
|
|
3337
|
+
client_name: "Off-origin surface",
|
|
3338
|
+
}),
|
|
3339
|
+
headers: { "content-type": "application/json" },
|
|
3340
|
+
});
|
|
3341
|
+
const res = await handleRegister(db, req, {
|
|
3342
|
+
issuer: ISSUER,
|
|
3343
|
+
hubBoundOrigins: boundOrigins,
|
|
3344
|
+
});
|
|
3345
|
+
expect(res.status).toBe(201);
|
|
3346
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
3347
|
+
const stored = body.redirect_uris as string[];
|
|
3348
|
+
// Stored exactly as submitted — never dropped.
|
|
3349
|
+
expect(stored).toEqual([foreign]);
|
|
3350
|
+
// No hub-origin variant was minted from the foreign URI (open-redirect guard).
|
|
3351
|
+
for (const o of [ISSUER, LOOPBACK, "http://localhost:1939", PUBLIC]) {
|
|
3352
|
+
expect(stored).not.toContain(`${o}/oauth/callback`);
|
|
3353
|
+
}
|
|
3354
|
+
} finally {
|
|
3355
|
+
cleanup();
|
|
3356
|
+
}
|
|
3357
|
+
});
|
|
3358
|
+
|
|
3359
|
+
test("no expansion when only one hub origin is known (single-origin hub unaffected)", async () => {
|
|
3360
|
+
const { db, cleanup } = await makeDb();
|
|
3361
|
+
try {
|
|
3362
|
+
const req = new Request(`${ISSUER}/oauth/register`, {
|
|
3363
|
+
method: "POST",
|
|
3364
|
+
body: JSON.stringify({
|
|
3365
|
+
redirect_uris: [`${ISSUER}/surface/notes/oauth/callback`],
|
|
3366
|
+
}),
|
|
3367
|
+
headers: { "content-type": "application/json" },
|
|
3368
|
+
});
|
|
3369
|
+
// hubBoundOrigins absent → resolveBoundOrigins falls back to [issuer].
|
|
3370
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
3371
|
+
expect(res.status).toBe(201);
|
|
3372
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
3373
|
+
expect(body.redirect_uris).toEqual([`${ISSUER}/surface/notes/oauth/callback`]);
|
|
3374
|
+
} finally {
|
|
3375
|
+
cleanup();
|
|
3376
|
+
}
|
|
3377
|
+
});
|
|
3378
|
+
});
|
|
3379
|
+
|
|
3282
3380
|
// closes #74 — DCR is now operator-gated. Self-served registrations land as
|
|
3283
3381
|
// pending and cannot OAuth; operator-bearer (hub:admin) registrations land
|
|
3284
3382
|
// as approved and can OAuth immediately. This block covers all four exposed
|
|
@@ -66,7 +66,7 @@ describe("mintOperatorToken", () => {
|
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/
|
|
69
|
+
test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/agent admins (#213)", () => {
|
|
70
70
|
// OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
|
|
71
71
|
// alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
|
|
72
72
|
// parachute:host:install/start/expose/auth/vault scopes to the admin
|
|
@@ -81,7 +81,7 @@ describe("mintOperatorToken", () => {
|
|
|
81
81
|
"parachute:host:vault",
|
|
82
82
|
"vault:admin",
|
|
83
83
|
"scribe:admin",
|
|
84
|
-
"
|
|
84
|
+
"agent:send",
|
|
85
85
|
]);
|
|
86
86
|
});
|
|
87
87
|
});
|
|
@@ -19,7 +19,7 @@ describe("SCOPE_EXPLANATIONS", () => {
|
|
|
19
19
|
"vault:admin",
|
|
20
20
|
"scribe:transcribe",
|
|
21
21
|
"scribe:admin",
|
|
22
|
-
"
|
|
22
|
+
"agent:send",
|
|
23
23
|
"hub:admin",
|
|
24
24
|
"parachute:host:admin",
|
|
25
25
|
];
|
|
@@ -96,7 +96,7 @@ describe("scopeIsAdmin", () => {
|
|
|
96
96
|
|
|
97
97
|
test("false for non-admin and unknown scopes", () => {
|
|
98
98
|
expect(scopeIsAdmin("vault:read")).toBe(false);
|
|
99
|
-
expect(scopeIsAdmin("
|
|
99
|
+
expect(scopeIsAdmin("agent:send")).toBe(false);
|
|
100
100
|
expect(scopeIsAdmin("unknown:anything")).toBe(false);
|
|
101
101
|
});
|
|
102
102
|
|
|
@@ -133,7 +133,7 @@ describe("isRequestableScope", () => {
|
|
|
133
133
|
expect(isRequestableScope("hub:admin")).toBe(true);
|
|
134
134
|
expect(isRequestableScope("vault:read")).toBe(true);
|
|
135
135
|
expect(isRequestableScope("vault:admin")).toBe(true);
|
|
136
|
-
expect(isRequestableScope("
|
|
136
|
+
expect(isRequestableScope("agent:send")).toBe(true);
|
|
137
137
|
});
|
|
138
138
|
|
|
139
139
|
test("true for unknown scopes (third-party module scopes pass through)", () => {
|
|
@@ -290,24 +290,24 @@ describe("bootSupervisedModules", () => {
|
|
|
290
290
|
});
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
-
// channel#41 — a transiently-wrong (drifted) services.json port for a
|
|
293
|
+
// agent (then channel)#41 — a transiently-wrong (drifted) services.json port for a
|
|
294
294
|
// fixed-port first-party module self-perpetuates: the supervisor injects PORT /
|
|
295
295
|
// probes / proxies from that row, so the wrong port strands the module forever.
|
|
296
296
|
// The boot path snaps it back to canonical before spawn AND persists the fix so
|
|
297
297
|
// the reverse-proxy (which reads services.json) routes correctly.
|
|
298
|
-
describe("reconcilePortToCanonical (channel#41)", () => {
|
|
298
|
+
describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
|
|
299
299
|
let h: Harness;
|
|
300
300
|
beforeEach(() => {
|
|
301
301
|
h = makeHarness();
|
|
302
302
|
});
|
|
303
303
|
afterEach(() => h.cleanup());
|
|
304
304
|
|
|
305
|
-
// The live-observed signature: channel
|
|
305
|
+
// The live-observed signature: the agent (then channel) row carried 19415 instead of its
|
|
306
306
|
// canonical 1941.
|
|
307
307
|
const DRIFTED_CHANNEL: ServiceEntry = {
|
|
308
|
-
name: "parachute-
|
|
308
|
+
name: "parachute-agent",
|
|
309
309
|
port: 19415,
|
|
310
|
-
paths: ["/
|
|
310
|
+
paths: ["/agent"],
|
|
311
311
|
health: "/health",
|
|
312
312
|
version: "0.1.0",
|
|
313
313
|
};
|
|
@@ -320,12 +320,12 @@ describe("reconcilePortToCanonical (channel#41)", () => {
|
|
|
320
320
|
logs.push(l),
|
|
321
321
|
);
|
|
322
322
|
|
|
323
|
-
// Returned entry carries canonical (
|
|
323
|
+
// Returned entry carries canonical (agent → 1941).
|
|
324
324
|
expect(reconciled.port).toBe(1941);
|
|
325
325
|
// And it's PERSISTED — the proxy reads services.json, so the row itself must
|
|
326
|
-
// now point at 1941 or `/
|
|
326
|
+
// now point at 1941 or `/agent/*` keeps routing to the dead 19415.
|
|
327
327
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
328
|
-
(s) => s.name === "parachute-
|
|
328
|
+
(s) => s.name === "parachute-agent",
|
|
329
329
|
);
|
|
330
330
|
expect(onDisk?.port).toBe(1941);
|
|
331
331
|
expect(
|
|
@@ -356,7 +356,7 @@ describe("reconcilePortToCanonical (channel#41)", () => {
|
|
|
356
356
|
|
|
357
357
|
test("does NOT steal the canonical port when another row already holds it", () => {
|
|
358
358
|
// Another row legitimately occupies 1941 — reconciling would trip the
|
|
359
|
-
// write-side duplicate-port guard and isn't
|
|
359
|
+
// write-side duplicate-port guard and isn't the agent module's to take. Leave the
|
|
360
360
|
// drift; the supervisor's squatter detection surfaces it.
|
|
361
361
|
const squatter: ServiceEntry = {
|
|
362
362
|
name: "parachute-vault",
|
|
@@ -372,13 +372,13 @@ describe("reconcilePortToCanonical (channel#41)", () => {
|
|
|
372
372
|
|
|
373
373
|
expect(out.port).toBe(19415); // unchanged
|
|
374
374
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
375
|
-
(s) => s.name === "parachute-
|
|
375
|
+
(s) => s.name === "parachute-agent",
|
|
376
376
|
);
|
|
377
377
|
expect(onDisk?.port).toBe(19415); // not rewritten
|
|
378
378
|
expect(logs.some((l) => l.includes("held by another row"))).toBe(true);
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
-
test("boot path injects PORT=canonical + persists the fix for a drifted
|
|
381
|
+
test("boot path injects PORT=canonical + persists the fix for a drifted agent row", async () => {
|
|
382
382
|
writeManifest({ services: [DRIFTED_CHANNEL] }, h.manifestPath);
|
|
383
383
|
const recorder = makeRecorder();
|
|
384
384
|
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
@@ -390,11 +390,11 @@ describe("reconcilePortToCanonical (channel#41)", () => {
|
|
|
390
390
|
|
|
391
391
|
// The supervisor child gets PORT=1941 (so it binds + the readiness probe
|
|
392
392
|
// checks the right port), not the drifted 19415.
|
|
393
|
-
expect(recorder.calls[0]?.short).toBe("
|
|
393
|
+
expect(recorder.calls[0]?.short).toBe("agent");
|
|
394
394
|
expect(recorder.calls[0]?.env?.PORT).toBe("1941");
|
|
395
|
-
// services.json row is reconciled → proxy routes /
|
|
395
|
+
// services.json row is reconciled → proxy routes /agent/* to 1941.
|
|
396
396
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
397
|
-
(s) => s.name === "parachute-
|
|
397
|
+
(s) => s.name === "parachute-agent",
|
|
398
398
|
);
|
|
399
399
|
expect(onDisk?.port).toBe(1941);
|
|
400
400
|
});
|
|
@@ -8,12 +8,38 @@ import {
|
|
|
8
8
|
formatBootstrapTokenBanner,
|
|
9
9
|
formatListeningBanner,
|
|
10
10
|
hubPortConflictMessage,
|
|
11
|
+
hubServeOptions,
|
|
11
12
|
resolveStartupIssuer,
|
|
12
13
|
seedInitialAdminIfNeeded,
|
|
13
14
|
} from "../commands/serve.ts";
|
|
14
15
|
import { openHubDb } from "../hub-db.ts";
|
|
15
16
|
import { getUserByUsername, userCount } from "../users.ts";
|
|
16
17
|
|
|
18
|
+
describe("hubServeOptions — the production listener wires the WS bridge", () => {
|
|
19
|
+
// Regression guard: the WS bridge handler was wired into hub-server.ts's
|
|
20
|
+
// Bun.serve but NOT this production `parachute serve` path, so module WS
|
|
21
|
+
// upgrades (the channel in-page terminal) 500'd through the hub
|
|
22
|
+
// ("set the websocket object in Bun.serve({})"). The listener MUST declare a
|
|
23
|
+
// websocket handler or `server.upgrade()` throws.
|
|
24
|
+
const fakeFetch = (() => new Response("ok")) as unknown as Parameters<typeof hubServeOptions>[0]["fetch"];
|
|
25
|
+
|
|
26
|
+
test("declares a websocket handler set (open/message/close)", () => {
|
|
27
|
+
const o = hubServeOptions({ port: 0, hostname: "127.0.0.1", fetch: fakeFetch });
|
|
28
|
+
expect(o.websocket).toBeDefined();
|
|
29
|
+
expect(typeof o.websocket.open).toBe("function");
|
|
30
|
+
expect(typeof o.websocket.message).toBe("function");
|
|
31
|
+
expect(typeof o.websocket.close).toBe("function");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("preserves the port/hostname/idleTimeout + the passed fetch", () => {
|
|
35
|
+
const o = hubServeOptions({ port: 1939, hostname: "0.0.0.0", fetch: fakeFetch });
|
|
36
|
+
expect(o.port).toBe(1939);
|
|
37
|
+
expect(o.hostname).toBe("0.0.0.0");
|
|
38
|
+
expect(o.idleTimeout).toBe(255);
|
|
39
|
+
expect(o.fetch).toBe(fakeFetch);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
17
43
|
describe("hubPortConflictMessage (hub#536)", () => {
|
|
18
44
|
test("maps a port-in-use error to a clear duplicate-supervisor message", () => {
|
|
19
45
|
// Bun surfaces a port conflict as "...Is port 1939 in use?"; node-style is
|