@openparachute/hub 0.6.5-rc.8 → 0.7.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/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
|
@@ -68,6 +68,24 @@ function deps() {
|
|
|
68
68
|
return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** Register an existing vault in the harness manifest (for shared-vault invites). */
|
|
72
|
+
function seedVaultInManifest(name: string) {
|
|
73
|
+
writeFileSync(
|
|
74
|
+
harness.manifestPath,
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
services: [
|
|
77
|
+
{
|
|
78
|
+
name: "parachute-vault",
|
|
79
|
+
port: 4101,
|
|
80
|
+
paths: [`/vault/${name}`],
|
|
81
|
+
health: "/health",
|
|
82
|
+
version: "0.0.0-test",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
|
|
72
90
|
const headers = new Headers(init.headers ?? {});
|
|
73
91
|
headers.set("authorization", `Bearer ${bearer}`);
|
|
@@ -121,23 +139,64 @@ describe("POST /api/invites", () => {
|
|
|
121
139
|
expect(res.status).toBe(400);
|
|
122
140
|
});
|
|
123
141
|
|
|
124
|
-
test("
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
142
|
+
test("shared-vault invite (provision_vault=false + EXISTING vault_name) → 201 at the given role", async () => {
|
|
143
|
+
// The Adam/Jonathan shape: read-only access to a vault the admin already
|
|
144
|
+
// built. Issuing is host:admin-gated — the same authority that can assign
|
|
145
|
+
// any user to any vault via POST /api/users.
|
|
146
|
+
seedVaultInManifest("jonathan-vault");
|
|
128
147
|
const bearer = await makeAdminBearer();
|
|
129
148
|
const res = await handleCreateInvite(
|
|
130
149
|
withBearer("/api/invites", bearer, {
|
|
131
150
|
method: "POST",
|
|
132
151
|
headers: { "content-type": "application/json" },
|
|
133
|
-
body: JSON.stringify({
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
provision_vault: false,
|
|
154
|
+
vault_name: "jonathan-vault",
|
|
155
|
+
role: "read",
|
|
156
|
+
}),
|
|
157
|
+
}),
|
|
158
|
+
deps(),
|
|
159
|
+
);
|
|
160
|
+
expect(res.status).toBe(201);
|
|
161
|
+
const body = (await res.json()) as {
|
|
162
|
+
invite: { vault_name: string | null; role: string; provision_vault: boolean };
|
|
163
|
+
};
|
|
164
|
+
expect(body.invite.vault_name).toBe("jonathan-vault");
|
|
165
|
+
expect(body.invite.role).toBe("read");
|
|
166
|
+
expect(body.invite.provision_vault).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("400 vault_not_found when the shared vault doesn't exist on this hub", async () => {
|
|
170
|
+
// A pinned-but-nonexistent name is a typo, not a future reservation —
|
|
171
|
+
// fail at mint, not at the invitee's redeem.
|
|
172
|
+
const bearer = await makeAdminBearer();
|
|
173
|
+
const res = await handleCreateInvite(
|
|
174
|
+
withBearer("/api/invites", bearer, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "content-type": "application/json" },
|
|
177
|
+
body: JSON.stringify({ provision_vault: false, vault_name: "nosuchvault", role: "read" }),
|
|
178
|
+
}),
|
|
179
|
+
deps(),
|
|
180
|
+
);
|
|
181
|
+
expect(res.status).toBe(400);
|
|
182
|
+
const body = (await res.json()) as { error: string };
|
|
183
|
+
expect(body.error).toBe("vault_not_found");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("400 rejects role=read with provision_vault=true (sole user of a new vault must hold write)", async () => {
|
|
187
|
+
const bearer = await makeAdminBearer();
|
|
188
|
+
const res = await handleCreateInvite(
|
|
189
|
+
withBearer("/api/invites", bearer, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: { "content-type": "application/json" },
|
|
192
|
+
body: JSON.stringify({ provision_vault: true, role: "read" }),
|
|
134
193
|
}),
|
|
135
194
|
deps(),
|
|
136
195
|
);
|
|
137
196
|
expect(res.status).toBe(400);
|
|
138
197
|
const body = (await res.json()) as { error: string; error_description: string };
|
|
139
198
|
expect(body.error).toBe("invalid_request");
|
|
140
|
-
expect(body.error_description).toContain("
|
|
199
|
+
expect(body.error_description).toContain("write");
|
|
141
200
|
});
|
|
142
201
|
|
|
143
202
|
test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
|
|
@@ -159,6 +218,107 @@ describe("POST /api/invites", () => {
|
|
|
159
218
|
});
|
|
160
219
|
});
|
|
161
220
|
|
|
221
|
+
describe("POST /api/invites — pre-named username", () => {
|
|
222
|
+
test("201 carries the username in the wire shape", async () => {
|
|
223
|
+
seedVaultInManifest("jonathan-vault");
|
|
224
|
+
const bearer = await makeAdminBearer();
|
|
225
|
+
const res = await handleCreateInvite(
|
|
226
|
+
withBearer("/api/invites", bearer, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "content-type": "application/json" },
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
username: "jonathan",
|
|
231
|
+
provision_vault: false,
|
|
232
|
+
vault_name: "jonathan-vault",
|
|
233
|
+
role: "read",
|
|
234
|
+
}),
|
|
235
|
+
}),
|
|
236
|
+
deps(),
|
|
237
|
+
);
|
|
238
|
+
expect(res.status).toBe(201);
|
|
239
|
+
const body = (await res.json()) as { invite: { username: string | null } };
|
|
240
|
+
expect(body.invite.username).toBe("jonathan");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("400 invalid_username on a name outside the /api/users vocabulary", async () => {
|
|
244
|
+
const bearer = await makeAdminBearer();
|
|
245
|
+
for (const bad of ["A", "Has Spaces", "admin"]) {
|
|
246
|
+
const res = await handleCreateInvite(
|
|
247
|
+
withBearer("/api/invites", bearer, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
body: JSON.stringify({ username: bad }),
|
|
251
|
+
}),
|
|
252
|
+
deps(),
|
|
253
|
+
);
|
|
254
|
+
expect(res.status).toBe(400);
|
|
255
|
+
const body = (await res.json()) as { error: string };
|
|
256
|
+
expect(body.error).toBe("invalid_username");
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("409 username_taken when an existing user holds the name (case-insensitive)", async () => {
|
|
261
|
+
const bearer = await makeAdminBearer(); // creates user "operator"
|
|
262
|
+
const res = await handleCreateInvite(
|
|
263
|
+
withBearer("/api/invites", bearer, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "content-type": "application/json" },
|
|
266
|
+
body: JSON.stringify({ username: "operator" }),
|
|
267
|
+
}),
|
|
268
|
+
deps(),
|
|
269
|
+
);
|
|
270
|
+
expect(res.status).toBe(409);
|
|
271
|
+
const body = (await res.json()) as { error: string };
|
|
272
|
+
expect(body.error).toBe("username_taken");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("409 username_reserved when another PENDING invite pre-names the same username", async () => {
|
|
276
|
+
const bearer = await makeAdminBearer();
|
|
277
|
+
const make = () =>
|
|
278
|
+
handleCreateInvite(
|
|
279
|
+
withBearer("/api/invites", bearer, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { "content-type": "application/json" },
|
|
282
|
+
body: JSON.stringify({ username: "jonathan" }),
|
|
283
|
+
}),
|
|
284
|
+
deps(),
|
|
285
|
+
);
|
|
286
|
+
const first = await make();
|
|
287
|
+
expect(first.status).toBe(201);
|
|
288
|
+
const second = await make();
|
|
289
|
+
expect(second.status).toBe(409);
|
|
290
|
+
const body = (await second.json()) as { error: string };
|
|
291
|
+
expect(body.error).toBe("username_reserved");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("revoking the pending invite frees the reserved username", async () => {
|
|
295
|
+
const bearer = await makeAdminBearer();
|
|
296
|
+
const first = await handleCreateInvite(
|
|
297
|
+
withBearer("/api/invites", bearer, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "content-type": "application/json" },
|
|
300
|
+
body: JSON.stringify({ username: "jonathan" }),
|
|
301
|
+
}),
|
|
302
|
+
deps(),
|
|
303
|
+
);
|
|
304
|
+
const { invite } = (await first.json()) as { invite: { id: string } };
|
|
305
|
+
await handleRevokeInvite(
|
|
306
|
+
withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
|
|
307
|
+
invite.id,
|
|
308
|
+
deps(),
|
|
309
|
+
);
|
|
310
|
+
const second = await handleCreateInvite(
|
|
311
|
+
withBearer("/api/invites", bearer, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: { "content-type": "application/json" },
|
|
314
|
+
body: JSON.stringify({ username: "jonathan" }),
|
|
315
|
+
}),
|
|
316
|
+
deps(),
|
|
317
|
+
);
|
|
318
|
+
expect(second.status).toBe(201);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
162
322
|
describe("GET /api/invites", () => {
|
|
163
323
|
test("lists invites; raw token NEVER present in the wire shape", async () => {
|
|
164
324
|
const bearer = await makeAdminBearer();
|
|
@@ -208,11 +208,27 @@ describe("parseModulesPath", () => {
|
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
test("
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
211
|
+
test("accepts any KNOWN module short — channel install now resolves (was the bug)", () => {
|
|
212
|
+
// Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
|
|
213
|
+
// `isKnownModuleShort` (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old
|
|
214
|
+
// CURATED_MODULES whitelist. channel is in FIRST_PARTY_FALLBACKS, so its
|
|
215
|
+
// install path now resolves — fixing the running-but-uninstallable bug.
|
|
216
|
+
expect(parseModulesPath("/api/modules/channel/install")).toEqual({
|
|
217
|
+
short: "channel",
|
|
218
|
+
rest: "install",
|
|
219
|
+
});
|
|
220
|
+
// Other known modules (runner / surface) resolve too.
|
|
221
|
+
expect(parseModulesPath("/api/modules/runner/install")).toEqual({
|
|
222
|
+
short: "runner",
|
|
223
|
+
rest: "install",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("rejects unknown shorts (the hub itself + genuinely third-party rows)", () => {
|
|
228
|
+
// `hub` isn't a supervised module (no registry entry) and a random short
|
|
229
|
+
// has no install package — both still fall through to undefined so the
|
|
230
|
+
// module-ops switch never acts on them.
|
|
231
|
+
expect(parseModulesPath("/api/modules/hub/install")).toBeUndefined();
|
|
216
232
|
expect(parseModulesPath("/api/modules/random/install")).toBeUndefined();
|
|
217
233
|
});
|
|
218
234
|
|
|
@@ -996,6 +1012,55 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
996
1012
|
expect(spawns).toEqual([]);
|
|
997
1013
|
expect(calls).toEqual([]);
|
|
998
1014
|
});
|
|
1015
|
+
|
|
1016
|
+
test("channel#41: start reconciles a drifted services.json port back to canonical (API path)", async () => {
|
|
1017
|
+
// The live signature: channel's row carried 19415 instead of canonical 1941.
|
|
1018
|
+
// The API start path (admin SPA / `parachute start channel`) must apply the
|
|
1019
|
+
// SAME reconcile the boot path does — otherwise an operator-triggered start
|
|
1020
|
+
// re-strands the module on the dead port.
|
|
1021
|
+
writeManifest(h.manifestPath, [
|
|
1022
|
+
{
|
|
1023
|
+
name: "parachute-channel",
|
|
1024
|
+
port: 19415,
|
|
1025
|
+
paths: ["/channel"],
|
|
1026
|
+
health: "/health",
|
|
1027
|
+
version: "0.0.0-linked",
|
|
1028
|
+
},
|
|
1029
|
+
]);
|
|
1030
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
1031
|
+
const logs: string[] = [];
|
|
1032
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1033
|
+
|
|
1034
|
+
const res = await handleStart(
|
|
1035
|
+
postReq("/api/modules/channel/start", { authorization: `Bearer ${bearer}` }),
|
|
1036
|
+
"channel",
|
|
1037
|
+
{
|
|
1038
|
+
db: h.db,
|
|
1039
|
+
issuer: ISSUER,
|
|
1040
|
+
manifestPath: h.manifestPath,
|
|
1041
|
+
configDir: h.dir,
|
|
1042
|
+
supervisor,
|
|
1043
|
+
log: (l) => logs.push(l),
|
|
1044
|
+
},
|
|
1045
|
+
);
|
|
1046
|
+
|
|
1047
|
+
expect(res.status).toBe(200);
|
|
1048
|
+
// The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
|
|
1049
|
+
// so it binds + the readiness probe checks the right port.
|
|
1050
|
+
expect(spawns.length).toBe(1);
|
|
1051
|
+
expect(spawns[0]?.short).toBe("channel");
|
|
1052
|
+
expect(spawns[0]?.env?.PORT).toBe("1941");
|
|
1053
|
+
// services.json row is rewritten to 1941 → the reverse-proxy (which reads
|
|
1054
|
+
// services.json) routes /channel/* to the live port.
|
|
1055
|
+
const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
1056
|
+
services: { name: string; port: number }[];
|
|
1057
|
+
};
|
|
1058
|
+
expect(onDisk.services.find((s) => s.name === "parachute-channel")?.port).toBe(1941);
|
|
1059
|
+
// The reconcile event logged on the API path too (deps.log wired — #41 review).
|
|
1060
|
+
expect(
|
|
1061
|
+
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
|
|
1062
|
+
).toBe(true);
|
|
1063
|
+
});
|
|
999
1064
|
});
|
|
1000
1065
|
|
|
1001
1066
|
describe("POST /api/modules/:short/stop", () => {
|