@openparachute/hub 0.7.0 → 0.7.2-rc.1
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__/account-setup.test.ts +276 -6
- 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 +1324 -0
- 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-invites.test.ts +166 -6
- 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__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- 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/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- 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 +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -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 +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- 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-C-XzMVqN.js +0 -61
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import {
|
|
6
6
|
InvalidRedirectUriError,
|
|
7
7
|
approveClient,
|
|
8
|
+
expandRedirectUrisForHubOrigins,
|
|
8
9
|
getClient,
|
|
9
10
|
isValidRedirectUri,
|
|
10
11
|
listClientsByStatus,
|
|
@@ -144,6 +145,96 @@ describe("requireRegisteredRedirectUri", () => {
|
|
|
144
145
|
});
|
|
145
146
|
});
|
|
146
147
|
|
|
148
|
+
describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expansion)", () => {
|
|
149
|
+
const LOOPBACK = "http://127.0.0.1:1939";
|
|
150
|
+
const PUBLIC = "https://box.taildf9ce2.ts.net";
|
|
151
|
+
const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
|
|
152
|
+
|
|
153
|
+
test("expands a loopback-rooted URI onto every other hub origin", () => {
|
|
154
|
+
const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/oauth/callback`], hubOrigins);
|
|
155
|
+
// Original is preserved + the public + localhost variants are added.
|
|
156
|
+
expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
157
|
+
expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
158
|
+
expect(out).toContain("http://localhost:1939/surface/notes/oauth/callback");
|
|
159
|
+
// The submitted URI comes first (order-preserving).
|
|
160
|
+
expect(out[0]).toBe(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("INVARIANT: a foreign-origin URI is stored verbatim — not expanded, not dropped", () => {
|
|
164
|
+
const foreign = "https://my-vault-ui.example/oauth/callback";
|
|
165
|
+
const out = expandRedirectUrisForHubOrigins([foreign], hubOrigins);
|
|
166
|
+
// Stored exactly as submitted...
|
|
167
|
+
expect(out).toContain(foreign);
|
|
168
|
+
// ...and NOTHING was minted on any hub origin from it (no open redirect).
|
|
169
|
+
expect(out).toEqual([foreign]);
|
|
170
|
+
for (const o of hubOrigins) {
|
|
171
|
+
expect(out).not.toContain(`${o}/oauth/callback`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("mixed submit: hub-origin URI expands, foreign URI rides verbatim alongside", () => {
|
|
176
|
+
const foreign = "https://evil.example/cb";
|
|
177
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
178
|
+
[`${LOOPBACK}/surface/notes/`, foreign],
|
|
179
|
+
hubOrigins,
|
|
180
|
+
);
|
|
181
|
+
// Hub-origin URI fanned out to the public origin.
|
|
182
|
+
expect(out).toContain(`${PUBLIC}/surface/notes/`);
|
|
183
|
+
// Foreign URI present, but no hub-origin variant of it exists.
|
|
184
|
+
expect(out).toContain(foreign);
|
|
185
|
+
for (const o of hubOrigins) {
|
|
186
|
+
expect(out).not.toContain(`${o}/cb`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("single known hub origin → no expansion (submitted set returned as-is)", () => {
|
|
191
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
192
|
+
[`${LOOPBACK}/surface/notes/`],
|
|
193
|
+
[LOOPBACK],
|
|
194
|
+
);
|
|
195
|
+
expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("dedupes — already-public + loopback submit doesn't double-register", () => {
|
|
199
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
200
|
+
[`${LOOPBACK}/surface/notes/`, `${PUBLIC}/surface/notes/`],
|
|
201
|
+
hubOrigins,
|
|
202
|
+
);
|
|
203
|
+
const publicCount = out.filter((u) => u === `${PUBLIC}/surface/notes/`).length;
|
|
204
|
+
expect(publicCount).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("path + query + hash are preserved across origins", () => {
|
|
208
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
209
|
+
[`${LOOPBACK}/surface/notes/oauth-callback?foo=bar`],
|
|
210
|
+
hubOrigins,
|
|
211
|
+
);
|
|
212
|
+
expect(out).toContain(`${PUBLIC}/surface/notes/oauth-callback?foo=bar`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("expanded URIs survive registerClient → requireRegisteredRedirectUri strict match", () => {
|
|
216
|
+
const { db, cleanup } = makeDb();
|
|
217
|
+
try {
|
|
218
|
+
const expanded = expandRedirectUrisForHubOrigins(
|
|
219
|
+
[`${LOOPBACK}/surface/notes/oauth/callback`],
|
|
220
|
+
hubOrigins,
|
|
221
|
+
);
|
|
222
|
+
const r = registerClient(db, { redirectUris: expanded });
|
|
223
|
+
// The public-origin variant now matches exactly at authorize time — the
|
|
224
|
+
// off-localhost sign-in that surface#118 broke.
|
|
225
|
+
expect(
|
|
226
|
+
requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`),
|
|
227
|
+
).toBe(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
228
|
+
// A truly-unregistered URI is still rejected — strict match unchanged.
|
|
229
|
+
expect(() =>
|
|
230
|
+
requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
|
|
231
|
+
).toThrow(InvalidRedirectUriError);
|
|
232
|
+
} finally {
|
|
233
|
+
cleanup();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
147
238
|
describe("verifyClientSecret", () => {
|
|
148
239
|
test("matches the issued secret, rejects others", () => {
|
|
149
240
|
const { db, cleanup } = makeDb();
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the agent-connector grant store (`grants-store.ts`, 4b-1).
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `grants.test.ts` (the OAuth consent skip-list in SQLite). This
|
|
5
|
+
* is the on-disk JSON store for approval-gated agent grants: round-trip, 0600
|
|
6
|
+
* perms (the file holds the granted secrets), idempotency-key derivation, and
|
|
7
|
+
* the lenient-read posture.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
type ConnectionSpec,
|
|
15
|
+
type GrantRecord,
|
|
16
|
+
connectionKey,
|
|
17
|
+
getGrant,
|
|
18
|
+
grantId,
|
|
19
|
+
listGrantsForAgent,
|
|
20
|
+
putGrant,
|
|
21
|
+
readGrants,
|
|
22
|
+
removeGrant,
|
|
23
|
+
} from "../grants-store.ts";
|
|
24
|
+
|
|
25
|
+
let dir: string;
|
|
26
|
+
let storePath: string;
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
dir = mkdtempSync(join(tmpdir(), "phub-grants-store-"));
|
|
29
|
+
storePath = join(dir, "agent-grants.json");
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function vaultSpec(over: Partial<ConnectionSpec> = {}): ConnectionSpec {
|
|
36
|
+
return { kind: "vault", target: "research", access: "read", ...over };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rec(over: Partial<GrantRecord> = {}): GrantRecord {
|
|
40
|
+
const connection = over.connection ?? vaultSpec();
|
|
41
|
+
return {
|
|
42
|
+
id: over.id ?? grantId("agent1", connection),
|
|
43
|
+
agent: over.agent ?? "agent1",
|
|
44
|
+
connection,
|
|
45
|
+
status: over.status ?? "pending",
|
|
46
|
+
createdAt: over.createdAt ?? "2026-06-17T00:00:00.000Z",
|
|
47
|
+
...(over.reason ? { reason: over.reason } : {}),
|
|
48
|
+
...(over.material ? { material: over.material } : {}),
|
|
49
|
+
...(over.approvedAt ? { approvedAt: over.approvedAt } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("round-trip", () => {
|
|
54
|
+
test("a missing file reads as empty", () => {
|
|
55
|
+
expect(readGrants(storePath)).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("put → read returns the record", () => {
|
|
59
|
+
const r = rec();
|
|
60
|
+
putGrant(storePath, r);
|
|
61
|
+
const back = readGrants(storePath);
|
|
62
|
+
expect(back).toHaveLength(1);
|
|
63
|
+
expect(back[0]).toEqual(r);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("put upserts by id (no duplicate row)", () => {
|
|
67
|
+
const r = rec();
|
|
68
|
+
putGrant(storePath, r);
|
|
69
|
+
putGrant(storePath, { ...r, status: "approved", approvedAt: "2026-06-18T00:00:00.000Z" });
|
|
70
|
+
const back = readGrants(storePath);
|
|
71
|
+
expect(back).toHaveLength(1);
|
|
72
|
+
expect(back[0]?.status).toBe("approved");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("getGrant + removeGrant", () => {
|
|
76
|
+
const r = rec();
|
|
77
|
+
putGrant(storePath, r);
|
|
78
|
+
expect(getGrant(storePath, r.id)?.id).toBe(r.id);
|
|
79
|
+
const removed = removeGrant(storePath, r.id);
|
|
80
|
+
expect(removed?.id).toBe(r.id);
|
|
81
|
+
expect(getGrant(storePath, r.id)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("listGrantsForAgent filters by agent (case-insensitive)", () => {
|
|
85
|
+
putGrant(storePath, rec({ id: "a", agent: "AgentOne" }));
|
|
86
|
+
putGrant(
|
|
87
|
+
storePath,
|
|
88
|
+
rec({ id: "b", agent: "agenttwo", connection: vaultSpec({ target: "x" }) }),
|
|
89
|
+
);
|
|
90
|
+
expect(listGrantsForAgent(storePath, "agentone")).toHaveLength(1);
|
|
91
|
+
expect(listGrantsForAgent(storePath, "AGENTTWO")).toHaveLength(1);
|
|
92
|
+
expect(listGrantsForAgent(storePath, "nope")).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("0600 perms (the file holds secrets)", () => {
|
|
97
|
+
test("the store file is created mode 0600", () => {
|
|
98
|
+
putGrant(
|
|
99
|
+
storePath,
|
|
100
|
+
rec({
|
|
101
|
+
status: "approved",
|
|
102
|
+
material: { kind: "service", token: "ghp_secret" },
|
|
103
|
+
connection: { kind: "service", target: "github" },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const mode = statSync(storePath).mode & 0o777;
|
|
107
|
+
expect(mode).toBe(0o600);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("lenient read", () => {
|
|
112
|
+
test("garbage JSON reads as empty", () => {
|
|
113
|
+
writeFileSync(storePath, "not json {{{");
|
|
114
|
+
expect(readGrants(storePath)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a top-level array (wrong shape) reads as empty", () => {
|
|
118
|
+
writeFileSync(storePath, JSON.stringify([{ id: "x" }]));
|
|
119
|
+
expect(readGrants(storePath)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a malformed row is dropped, valid rows survive", () => {
|
|
123
|
+
const valid = rec();
|
|
124
|
+
writeFileSync(
|
|
125
|
+
storePath,
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
grants: [
|
|
128
|
+
valid,
|
|
129
|
+
{ id: "missing-agent" }, // no agent/connection/status → dropped
|
|
130
|
+
{ id: "bad-status", agent: "a", connection: vaultSpec(), status: "weird" }, // bad status → dropped
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
const back = readGrants(storePath);
|
|
135
|
+
expect(back).toHaveLength(1);
|
|
136
|
+
expect(back[0]?.id).toBe(valid.id);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("4b-2 mcp material + needs_consent (regression)", () => {
|
|
141
|
+
test("mcp material round-trips (OAuth shape)", () => {
|
|
142
|
+
const r = rec({
|
|
143
|
+
status: "approved",
|
|
144
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
145
|
+
material: {
|
|
146
|
+
kind: "mcp",
|
|
147
|
+
access_token: "at-1",
|
|
148
|
+
refresh_token: "rt-1",
|
|
149
|
+
expiresAt: "2026-06-18T13:00:00.000Z",
|
|
150
|
+
issuer: "https://issuer.test",
|
|
151
|
+
clientId: "cid-1",
|
|
152
|
+
tokenEndpoint: "https://issuer.test/oauth/token",
|
|
153
|
+
revocationEndpoint: "https://issuer.test/oauth/revoke",
|
|
154
|
+
mcpUrl: "https://remote.test/mcp",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
putGrant(storePath, r);
|
|
158
|
+
const back = readGrants(storePath);
|
|
159
|
+
expect(back).toHaveLength(1);
|
|
160
|
+
expect(back[0]).toEqual(r);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("mcp static-bearer material round-trips (no refresh)", () => {
|
|
164
|
+
const r = rec({
|
|
165
|
+
status: "approved",
|
|
166
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
167
|
+
material: { kind: "mcp", access_token: "static-paste", mcpUrl: "https://remote.test/mcp" },
|
|
168
|
+
});
|
|
169
|
+
putGrant(storePath, r);
|
|
170
|
+
expect(readGrants(storePath)[0]).toEqual(r);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a needs_consent row SURVIVES readGrants (the 4b-2 blocker regression)", () => {
|
|
174
|
+
const r = rec({
|
|
175
|
+
status: "needs_consent",
|
|
176
|
+
reason: "refresh failed: invalid_grant",
|
|
177
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
178
|
+
});
|
|
179
|
+
putGrant(storePath, r);
|
|
180
|
+
const back = readGrants(storePath);
|
|
181
|
+
expect(back).toHaveLength(1);
|
|
182
|
+
expect(back[0]?.status).toBe("needs_consent");
|
|
183
|
+
expect(getGrant(storePath, r.id)?.status).toBe("needs_consent");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("connectionKey / grantId derivation (idempotency)", () => {
|
|
188
|
+
test("vault key includes access + sorted tags", () => {
|
|
189
|
+
expect(connectionKey(vaultSpec())).toBe("vault:research:read");
|
|
190
|
+
expect(connectionKey(vaultSpec({ access: "write" }))).toBe("vault:research:write");
|
|
191
|
+
// tag order doesn't change the key
|
|
192
|
+
const a = connectionKey(vaultSpec({ tags: ["#b", "#a"] }));
|
|
193
|
+
const b = connectionKey(vaultSpec({ tags: ["#a", "#b"] }));
|
|
194
|
+
expect(a).toBe(b);
|
|
195
|
+
expect(a).toBe("vault:research:read##a,#b");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("service key ignores inject (re-declare with new inject → same key)", () => {
|
|
199
|
+
const k1 = connectionKey({ kind: "service", target: "github", inject: ["env"] });
|
|
200
|
+
const k2 = connectionKey({ kind: "service", target: "github", inject: ["env", "mcp"] });
|
|
201
|
+
expect(k1).toBe(k2);
|
|
202
|
+
expect(k1).toBe("service:github");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("mcp key is the url target", () => {
|
|
206
|
+
expect(connectionKey({ kind: "mcp", target: "https://x.test/mcp" })).toBe(
|
|
207
|
+
"mcp:https://x.test/mcp",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("grantId is a stable URL-safe slug from agent + key", () => {
|
|
212
|
+
const id = grantId("agent1", vaultSpec());
|
|
213
|
+
expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
|
214
|
+
// deterministic
|
|
215
|
+
expect(grantId("agent1", vaultSpec())).toBe(id);
|
|
216
|
+
// different connection → different id
|
|
217
|
+
expect(grantId("agent1", vaultSpec({ access: "write" }))).not.toBe(id);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -380,6 +380,42 @@ describe("openHubDb + migrate", () => {
|
|
|
380
380
|
}
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
test("v13 adds invites.username; pre-existing invite rows keep NULL (no backfill)", () => {
|
|
384
|
+
const h = makeHarness();
|
|
385
|
+
try {
|
|
386
|
+
const db = openHubDb(h.dbPath);
|
|
387
|
+
try {
|
|
388
|
+
// Simulate a real v12 install: strip the v13 column, mark v13
|
|
389
|
+
// unapplied, and seed an invite row that pre-dates pre-naming.
|
|
390
|
+
db.exec(`
|
|
391
|
+
ALTER TABLE invites DROP COLUMN username;
|
|
392
|
+
INSERT INTO invites (token, created_by, vault_name, role, provision_vault,
|
|
393
|
+
default_mirror, expires_at, created_at)
|
|
394
|
+
VALUES ('hash-v12', NULL, 'maya', 'write', 1, NULL,
|
|
395
|
+
'2099-01-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z');
|
|
396
|
+
`);
|
|
397
|
+
db.exec("DELETE FROM schema_version WHERE version = 13");
|
|
398
|
+
migrate(db);
|
|
399
|
+
const cols = db
|
|
400
|
+
.query<{ name: string }, []>("SELECT name FROM pragma_table_info('invites')")
|
|
401
|
+
.all()
|
|
402
|
+
.map((c) => c.name);
|
|
403
|
+
expect(cols).toContain("username");
|
|
404
|
+
// Grandfathered row → NULL (redeemer picks their own name).
|
|
405
|
+
const row = db
|
|
406
|
+
.query<{ username: string | null }, [string]>(
|
|
407
|
+
"SELECT username FROM invites WHERE token = ?",
|
|
408
|
+
)
|
|
409
|
+
.get("hash-v12");
|
|
410
|
+
expect(row?.username).toBeNull();
|
|
411
|
+
} finally {
|
|
412
|
+
db.close();
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
h.cleanup();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
383
419
|
test("v10 FK cascade: deleting a user drops their user_vaults rows", () => {
|
|
384
420
|
const h = makeHarness();
|
|
385
421
|
try {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
hubFetch,
|
|
14
14
|
layerOf,
|
|
15
15
|
parseArgs,
|
|
16
|
+
resolveClientIp,
|
|
16
17
|
} from "../hub-server.ts";
|
|
17
18
|
import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
18
19
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
@@ -1575,8 +1576,10 @@ describe("hubFetch routing", () => {
|
|
|
1575
1576
|
});
|
|
1576
1577
|
|
|
1577
1578
|
// Pre-admin lockout (hub#258). When no admin row exists, operator-
|
|
1578
|
-
// facing surfaces (admin/api
|
|
1579
|
-
// /admin/setup.
|
|
1579
|
+
// facing API surfaces (admin/api) 503 with a JSON body pointing at
|
|
1580
|
+
// /admin/setup. The browser-facing `/login` GET 302s to the wizard
|
|
1581
|
+
// instead (hub#644) — a JSON 503 there would render as raw text in the
|
|
1582
|
+
// visitor's tab. Public surfaces (health, well-known, /, oauth, vault,
|
|
1580
1583
|
// /admin/setup itself) stay open so the container is reachable and
|
|
1581
1584
|
// OAuth third parties aren't held hostage by admin onboarding.
|
|
1582
1585
|
test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
|
|
@@ -1614,7 +1617,7 @@ describe("hubFetch routing", () => {
|
|
|
1614
1617
|
}
|
|
1615
1618
|
});
|
|
1616
1619
|
|
|
1617
|
-
test("pre-admin lockout: /login
|
|
1620
|
+
test("pre-admin lockout: /login GET funnels to /admin/setup, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
|
|
1618
1621
|
const h = makeHarness();
|
|
1619
1622
|
try {
|
|
1620
1623
|
writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
|
|
@@ -1622,9 +1625,11 @@ describe("hubFetch routing", () => {
|
|
|
1622
1625
|
const db = openHubDb(hubDbPath(h.dir));
|
|
1623
1626
|
try {
|
|
1624
1627
|
const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
|
|
1625
|
-
// /login
|
|
1628
|
+
// /login GET funnels to the wizard (hub#644) — the browser-facing
|
|
1629
|
+
// sign-in surface 302s to /admin/setup instead of a raw JSON 503.
|
|
1626
1630
|
const loginRes = await handler(req("/login"));
|
|
1627
|
-
expect(loginRes.status).toBe(
|
|
1631
|
+
expect(loginRes.status).toBe(302);
|
|
1632
|
+
expect(loginRes.headers.get("location")).toBe("/admin/setup");
|
|
1628
1633
|
// /admin/setup open
|
|
1629
1634
|
const setupRes = await handler(req("/admin/setup"));
|
|
1630
1635
|
expect(setupRes.status).toBe(200);
|
|
@@ -5580,3 +5585,268 @@ describe("force-change-password per-request gate (#469)", () => {
|
|
|
5580
5585
|
}
|
|
5581
5586
|
});
|
|
5582
5587
|
});
|
|
5588
|
+
|
|
5589
|
+
describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP (H2)", () => {
|
|
5590
|
+
// The hub stamps the layerOf classification + resolved client IP on every
|
|
5591
|
+
// request it forwards to a module upstream, STRIPPING any inbound
|
|
5592
|
+
// occurrences first (a public client must not be able to inject a forged
|
|
5593
|
+
// "loopback" trust signal past the proxy). Surface-runtime design §10.
|
|
5594
|
+
|
|
5595
|
+
function startEchoUpstream(): { port: number; stop: () => void } {
|
|
5596
|
+
const server = Bun.serve({
|
|
5597
|
+
port: 0,
|
|
5598
|
+
hostname: "127.0.0.1",
|
|
5599
|
+
fetch: (req) =>
|
|
5600
|
+
new Response(
|
|
5601
|
+
JSON.stringify({
|
|
5602
|
+
layer: req.headers.get("x-parachute-layer"),
|
|
5603
|
+
clientIp: req.headers.get("x-parachute-client-ip"),
|
|
5604
|
+
}),
|
|
5605
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
5606
|
+
),
|
|
5607
|
+
});
|
|
5608
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
5609
|
+
}
|
|
5610
|
+
|
|
5611
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
5612
|
+
|
|
5613
|
+
function writeEchoService(h: Harness, port: number): void {
|
|
5614
|
+
writeManifest(
|
|
5615
|
+
{
|
|
5616
|
+
services: [
|
|
5617
|
+
{
|
|
5618
|
+
name: "echo-svc",
|
|
5619
|
+
port,
|
|
5620
|
+
paths: ["/echo-svc"],
|
|
5621
|
+
health: "/echo-svc/health",
|
|
5622
|
+
version: "0.1.0",
|
|
5623
|
+
},
|
|
5624
|
+
],
|
|
5625
|
+
},
|
|
5626
|
+
h.manifestPath,
|
|
5627
|
+
);
|
|
5628
|
+
}
|
|
5629
|
+
|
|
5630
|
+
test("loopback direct-to-hub → stamped loopback + peer IP (generic proxy)", async () => {
|
|
5631
|
+
const h = makeHarness();
|
|
5632
|
+
const upstream = startEchoUpstream();
|
|
5633
|
+
try {
|
|
5634
|
+
writeEchoService(h, upstream.port);
|
|
5635
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5636
|
+
const res = await fetcher(req("/echo-svc/x"), fakeServer("127.0.0.1"));
|
|
5637
|
+
expect(res.status).toBe(200);
|
|
5638
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5639
|
+
expect(body.layer).toBe("loopback");
|
|
5640
|
+
expect(body.clientIp).toBe("127.0.0.1");
|
|
5641
|
+
} finally {
|
|
5642
|
+
upstream.stop();
|
|
5643
|
+
h.cleanup();
|
|
5644
|
+
}
|
|
5645
|
+
});
|
|
5646
|
+
|
|
5647
|
+
test("tailnet request → stamped tailnet + X-Forwarded-For client IP", async () => {
|
|
5648
|
+
const h = makeHarness();
|
|
5649
|
+
const upstream = startEchoUpstream();
|
|
5650
|
+
try {
|
|
5651
|
+
writeEchoService(h, upstream.port);
|
|
5652
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5653
|
+
const res = await fetcher(
|
|
5654
|
+
req("/echo-svc/x", {
|
|
5655
|
+
headers: {
|
|
5656
|
+
"tailscale-user-login": "aaron@example.com",
|
|
5657
|
+
"x-forwarded-for": "100.64.0.7",
|
|
5658
|
+
},
|
|
5659
|
+
}),
|
|
5660
|
+
fakeServer("127.0.0.1"),
|
|
5661
|
+
);
|
|
5662
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5663
|
+
expect(body.layer).toBe("tailnet");
|
|
5664
|
+
expect(body.clientIp).toBe("100.64.0.7");
|
|
5665
|
+
} finally {
|
|
5666
|
+
upstream.stop();
|
|
5667
|
+
h.cleanup();
|
|
5668
|
+
}
|
|
5669
|
+
});
|
|
5670
|
+
|
|
5671
|
+
test("public (cloudflared) request → stamped public + CF-Connecting-IP", async () => {
|
|
5672
|
+
const h = makeHarness();
|
|
5673
|
+
const upstream = startEchoUpstream();
|
|
5674
|
+
try {
|
|
5675
|
+
writeEchoService(h, upstream.port);
|
|
5676
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5677
|
+
const res = await fetcher(
|
|
5678
|
+
req("/echo-svc/x", {
|
|
5679
|
+
headers: { "cf-ray": "8abc123", "cf-connecting-ip": "203.0.113.9" },
|
|
5680
|
+
}),
|
|
5681
|
+
fakeServer("127.0.0.1"),
|
|
5682
|
+
);
|
|
5683
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5684
|
+
expect(body.layer).toBe("public");
|
|
5685
|
+
expect(body.clientIp).toBe("203.0.113.9");
|
|
5686
|
+
} finally {
|
|
5687
|
+
upstream.stop();
|
|
5688
|
+
h.cleanup();
|
|
5689
|
+
}
|
|
5690
|
+
});
|
|
5691
|
+
|
|
5692
|
+
test("inbound spoof is STRIPPED — public peer injecting loopback headers gets re-stamped", async () => {
|
|
5693
|
+
// The attack H2's strip exists for: a direct network peer (0.0.0.0 bind)
|
|
5694
|
+
// sends X-Parachute-Layer: loopback + a forged client IP. The hub must
|
|
5695
|
+
// strip both and stamp its own fail-closed classification.
|
|
5696
|
+
const h = makeHarness();
|
|
5697
|
+
const upstream = startEchoUpstream();
|
|
5698
|
+
try {
|
|
5699
|
+
writeEchoService(h, upstream.port);
|
|
5700
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5701
|
+
const res = await fetcher(
|
|
5702
|
+
req("/echo-svc/x", {
|
|
5703
|
+
headers: {
|
|
5704
|
+
"x-parachute-layer": "loopback",
|
|
5705
|
+
"x-parachute-client-ip": "127.0.0.1",
|
|
5706
|
+
},
|
|
5707
|
+
}),
|
|
5708
|
+
fakeServer("198.51.100.20"),
|
|
5709
|
+
);
|
|
5710
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5711
|
+
expect(body.layer).toBe("public"); // header-absent non-loopback peer → public
|
|
5712
|
+
expect(body.clientIp).toBe("198.51.100.20"); // peer address, not the forgery
|
|
5713
|
+
} finally {
|
|
5714
|
+
upstream.stop();
|
|
5715
|
+
h.cleanup();
|
|
5716
|
+
}
|
|
5717
|
+
});
|
|
5718
|
+
|
|
5719
|
+
test("direct caller injecting CF-Connecting-IP: layer stamp stays sound (public), client IP is best-effort attribution", async () => {
|
|
5720
|
+
// Pins the documented property (resolveClientIp's "Known limitation"):
|
|
5721
|
+
// a DIRECT caller can spoof the forwarded-IP headers and misattribute
|
|
5722
|
+
// its own address — X-Parachute-Client-IP reflects the injected value —
|
|
5723
|
+
// but it CANNOT spoof the LAYER: the CF header's presence classifies the
|
|
5724
|
+
// request "public" regardless of the peer (here even a loopback one),
|
|
5725
|
+
// so spoofing only ever moves the trust signal DOWN. Layer is the
|
|
5726
|
+
// security signal; client IP is attribution.
|
|
5727
|
+
const h = makeHarness();
|
|
5728
|
+
const upstream = startEchoUpstream();
|
|
5729
|
+
try {
|
|
5730
|
+
writeEchoService(h, upstream.port);
|
|
5731
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5732
|
+
const res = await fetcher(
|
|
5733
|
+
req("/echo-svc/x", {
|
|
5734
|
+
headers: { "cf-connecting-ip": "203.0.113.50" },
|
|
5735
|
+
}),
|
|
5736
|
+
fakeServer("127.0.0.1"),
|
|
5737
|
+
);
|
|
5738
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5739
|
+
expect(body.layer).toBe("public"); // CF header presence → public, not loopback
|
|
5740
|
+
expect(body.clientIp).toBe("203.0.113.50"); // injected value — best-effort by design
|
|
5741
|
+
} finally {
|
|
5742
|
+
upstream.stop();
|
|
5743
|
+
h.cleanup();
|
|
5744
|
+
}
|
|
5745
|
+
});
|
|
5746
|
+
|
|
5747
|
+
test("unknown peer (no Server threaded) → fail-closed public, header for client IP omitted", async () => {
|
|
5748
|
+
const h = makeHarness();
|
|
5749
|
+
const upstream = startEchoUpstream();
|
|
5750
|
+
try {
|
|
5751
|
+
writeEchoService(h, upstream.port);
|
|
5752
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5753
|
+
// No second arg — peerAddr resolves null; layer fails closed to public.
|
|
5754
|
+
const res = await fetcher(req("/echo-svc/x"));
|
|
5755
|
+
const body = (await res.json()) as { layer: string; clientIp: string | null };
|
|
5756
|
+
expect(body.layer).toBe("public");
|
|
5757
|
+
expect(body.clientIp).toBeNull();
|
|
5758
|
+
} finally {
|
|
5759
|
+
upstream.stop();
|
|
5760
|
+
h.cleanup();
|
|
5761
|
+
}
|
|
5762
|
+
});
|
|
5763
|
+
|
|
5764
|
+
test("per-vault proxy stamps the same headers (shared proxyRequest path)", async () => {
|
|
5765
|
+
const h = makeHarness();
|
|
5766
|
+
const upstream = startEchoUpstream();
|
|
5767
|
+
try {
|
|
5768
|
+
writeManifest(
|
|
5769
|
+
{
|
|
5770
|
+
services: [
|
|
5771
|
+
{
|
|
5772
|
+
name: "parachute-vault-default",
|
|
5773
|
+
port: upstream.port,
|
|
5774
|
+
paths: ["/vault/default"],
|
|
5775
|
+
health: "/health",
|
|
5776
|
+
version: "0.4.0",
|
|
5777
|
+
},
|
|
5778
|
+
],
|
|
5779
|
+
},
|
|
5780
|
+
h.manifestPath,
|
|
5781
|
+
);
|
|
5782
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5783
|
+
const res = await fetcher(req("/vault/default/api/notes"), fakeServer("127.0.0.1"));
|
|
5784
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5785
|
+
expect(body.layer).toBe("loopback");
|
|
5786
|
+
expect(body.clientIp).toBe("127.0.0.1");
|
|
5787
|
+
} finally {
|
|
5788
|
+
upstream.stop();
|
|
5789
|
+
h.cleanup();
|
|
5790
|
+
}
|
|
5791
|
+
});
|
|
5792
|
+
|
|
5793
|
+
test("/vault/admin route stamps the same headers (proxyToVaultAdmin path)", async () => {
|
|
5794
|
+
const h = makeHarness();
|
|
5795
|
+
const upstream = startEchoUpstream();
|
|
5796
|
+
try {
|
|
5797
|
+
writeManifest(
|
|
5798
|
+
{
|
|
5799
|
+
services: [
|
|
5800
|
+
{
|
|
5801
|
+
name: "parachute-vault",
|
|
5802
|
+
port: upstream.port,
|
|
5803
|
+
paths: ["/vault/default"],
|
|
5804
|
+
health: "/health",
|
|
5805
|
+
version: "0.5.0",
|
|
5806
|
+
},
|
|
5807
|
+
],
|
|
5808
|
+
},
|
|
5809
|
+
h.manifestPath,
|
|
5810
|
+
);
|
|
5811
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5812
|
+
const res = await fetcher(
|
|
5813
|
+
req("/vault/admin/", {
|
|
5814
|
+
headers: { "tailscale-user-login": "aaron@example.com" },
|
|
5815
|
+
}),
|
|
5816
|
+
fakeServer("127.0.0.1"),
|
|
5817
|
+
);
|
|
5818
|
+
const body = (await res.json()) as { layer: string };
|
|
5819
|
+
expect(body.layer).toBe("tailnet");
|
|
5820
|
+
} finally {
|
|
5821
|
+
upstream.stop();
|
|
5822
|
+
h.cleanup();
|
|
5823
|
+
}
|
|
5824
|
+
});
|
|
5825
|
+
});
|
|
5826
|
+
|
|
5827
|
+
describe("resolveClientIp (H2)", () => {
|
|
5828
|
+
test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
|
|
5829
|
+
const r = req("/", {
|
|
5830
|
+
headers: { "cf-connecting-ip": "203.0.113.1", "x-forwarded-for": "10.0.0.1" },
|
|
5831
|
+
});
|
|
5832
|
+
expect(resolveClientIp(r, "127.0.0.1")).toBe("203.0.113.1");
|
|
5833
|
+
});
|
|
5834
|
+
|
|
5835
|
+
test("X-Forwarded-For first hop wins over peer", () => {
|
|
5836
|
+
const r = req("/", { headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" } });
|
|
5837
|
+
expect(resolveClientIp(r, "127.0.0.1")).toBe("10.0.0.1");
|
|
5838
|
+
});
|
|
5839
|
+
|
|
5840
|
+
test("falls back to the peer address", () => {
|
|
5841
|
+
expect(resolveClientIp(req("/"), "198.51.100.7")).toBe("198.51.100.7");
|
|
5842
|
+
});
|
|
5843
|
+
|
|
5844
|
+
test("null when nothing resolves", () => {
|
|
5845
|
+
expect(resolveClientIp(req("/"), null)).toBeNull();
|
|
5846
|
+
});
|
|
5847
|
+
|
|
5848
|
+
test("whitespace-only header values are treated as absent", () => {
|
|
5849
|
+
const r = req("/", { headers: { "x-forwarded-for": " " } });
|
|
5850
|
+
expect(resolveClientIp(r, null)).toBeNull();
|
|
5851
|
+
});
|
|
5852
|
+
});
|