@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -11
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-vaults.ts +77 -27
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +52 -4
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +173 -25
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +110 -7
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -116,7 +116,8 @@ describe("renderConsent", () => {
|
|
|
116
116
|
expect(html).toContain("vault:admin");
|
|
117
117
|
// Scope explanations from the registry
|
|
118
118
|
expect(html).toContain("Read your notes");
|
|
119
|
-
|
|
119
|
+
// hub#689 Leg 1: the admin label now enumerates the concrete grants.
|
|
120
|
+
expect(html).toContain("Read and write everything, plus admin");
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
test("highlights admin scopes with a danger color and badge", () => {
|
|
@@ -252,6 +253,111 @@ describe("renderConsent", () => {
|
|
|
252
253
|
expect(html).not.toContain("You have no assigned vaults");
|
|
253
254
|
expect(html).not.toContain('value="yes" class="btn btn-primary" disabled');
|
|
254
255
|
});
|
|
256
|
+
|
|
257
|
+
// hub#689 — owner-on-own-vault verb selector rendering.
|
|
258
|
+
test("renders the owner verb selector (read/write/admin), pre-selected to admin", () => {
|
|
259
|
+
const html = renderConsent({
|
|
260
|
+
params: { ...PARAMS, scope: "vault:read" },
|
|
261
|
+
csrfToken: CSRF,
|
|
262
|
+
clientId: "c",
|
|
263
|
+
clientName: "App",
|
|
264
|
+
scopes: ["vault:read"],
|
|
265
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
|
|
266
|
+
ownerVerbSelector: { requestedVerbs: ["read"] },
|
|
267
|
+
});
|
|
268
|
+
expect(html).toContain("Access level");
|
|
269
|
+
expect(html).toContain('name="verb_select" value="read"');
|
|
270
|
+
expect(html).toContain('name="verb_select" value="write"');
|
|
271
|
+
expect(html).toContain('name="verb_select" value="admin"');
|
|
272
|
+
// Admin is the pre-selected (checked) option.
|
|
273
|
+
expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
|
|
274
|
+
// read/write are NOT pre-checked.
|
|
275
|
+
expect(html).not.toMatch(/name="verb_select" value="read"[^>]*checked/);
|
|
276
|
+
expect(html).not.toMatch(/name="verb_select" value="write"[^>]*checked/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("owner verb selector keeps the admin option visibly flagged (admin badge + red border)", () => {
|
|
280
|
+
const html = renderConsent({
|
|
281
|
+
params: { ...PARAMS, scope: "vault:read" },
|
|
282
|
+
csrfToken: CSRF,
|
|
283
|
+
clientId: "c",
|
|
284
|
+
clientName: "App",
|
|
285
|
+
scopes: ["vault:read"],
|
|
286
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
|
|
287
|
+
ownerVerbSelector: { requestedVerbs: ["read"] },
|
|
288
|
+
});
|
|
289
|
+
// The .scope-admin red-border class + the admin badge ride on the admin
|
|
290
|
+
// radio option so a pre-selected admin grant stays transparent.
|
|
291
|
+
expect(html).toContain("verb-option-admin");
|
|
292
|
+
expect(html).toContain("scope-admin");
|
|
293
|
+
expect(html).toContain("badge-admin");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("does NOT render the verb selector when ownerVerbSelector is absent (non-owner)", () => {
|
|
297
|
+
const html = renderConsent({
|
|
298
|
+
params: { ...PARAMS, scope: "vault:read" },
|
|
299
|
+
csrfToken: CSRF,
|
|
300
|
+
clientId: "c",
|
|
301
|
+
clientName: "App",
|
|
302
|
+
scopes: ["vault:read"],
|
|
303
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
|
|
304
|
+
// ownerVerbSelector omitted → no selector
|
|
305
|
+
});
|
|
306
|
+
expect(html).not.toContain("Access level");
|
|
307
|
+
expect(html).not.toContain('name="verb_select"');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// hub#314 — same-hub vs external trust marker. The `.badge-trust-*` class
|
|
311
|
+
// names are always present in the inlined <style> block, so assertions target
|
|
312
|
+
// the RENDERED ELEMENT form (`class="badge badge-trust-*"`) + the copy text,
|
|
313
|
+
// which only appear in the consent body when the marker actually renders.
|
|
314
|
+
test("renders the first-party trust marker for a same-hub client", () => {
|
|
315
|
+
const html = renderConsent({
|
|
316
|
+
params: PARAMS,
|
|
317
|
+
csrfToken: CSRF,
|
|
318
|
+
clientId: "c",
|
|
319
|
+
clientName: "App",
|
|
320
|
+
scopes: ["vault:read"],
|
|
321
|
+
sameHub: true,
|
|
322
|
+
});
|
|
323
|
+
expect(html).toContain('class="badge badge-trust-same-hub"');
|
|
324
|
+
expect(html).toContain(">First-party<");
|
|
325
|
+
expect(html).toContain("Registered through this hub");
|
|
326
|
+
// The external badge / copy must NOT appear for a same-hub client.
|
|
327
|
+
expect(html).not.toContain('class="badge badge-trust-external"');
|
|
328
|
+
expect(html).not.toContain("third-party app that registered itself");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("renders the external trust marker for a third-party DCR client", () => {
|
|
332
|
+
const html = renderConsent({
|
|
333
|
+
params: PARAMS,
|
|
334
|
+
csrfToken: CSRF,
|
|
335
|
+
clientId: "c",
|
|
336
|
+
clientName: "App",
|
|
337
|
+
scopes: ["vault:read"],
|
|
338
|
+
sameHub: false,
|
|
339
|
+
});
|
|
340
|
+
expect(html).toContain('class="badge badge-trust-external"');
|
|
341
|
+
expect(html).toContain(">External<");
|
|
342
|
+
expect(html).toContain("third-party app that registered itself");
|
|
343
|
+
// The first-party badge / copy must NOT appear for an external client.
|
|
344
|
+
expect(html).not.toContain('class="badge badge-trust-same-hub"');
|
|
345
|
+
expect(html).not.toContain("Registered through this hub");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("renders no trust marker when provenance is unknown (sameHub omitted)", () => {
|
|
349
|
+
const html = renderConsent({
|
|
350
|
+
params: PARAMS,
|
|
351
|
+
csrfToken: CSRF,
|
|
352
|
+
clientId: "c",
|
|
353
|
+
clientName: "App",
|
|
354
|
+
scopes: ["vault:read"],
|
|
355
|
+
// sameHub omitted → undefined → no badge
|
|
356
|
+
});
|
|
357
|
+
expect(html).not.toContain('class="trust-marker');
|
|
358
|
+
expect(html).not.toContain('class="badge badge-trust-same-hub"');
|
|
359
|
+
expect(html).not.toContain('class="badge badge-trust-external"');
|
|
360
|
+
});
|
|
255
361
|
});
|
|
256
362
|
|
|
257
363
|
describe("renderError", () => {
|
|
@@ -29,6 +29,25 @@ describe("SCOPE_EXPLANATIONS", () => {
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// hub#689 Leg 1: the vault:admin consent copy must enumerate what
|
|
33
|
+
// admin actually grants (config/settings, triggers/automation, GitHub
|
|
34
|
+
// backup, token minting) on top of read/write — so the consent screen
|
|
35
|
+
// is honest about the admin blast radius, not a vague "configuration
|
|
36
|
+
// changes" hand-wave.
|
|
37
|
+
test("vault:admin label enumerates the concrete admin grants (hub#689 Leg 1)", () => {
|
|
38
|
+
const label = SCOPE_EXPLANATIONS["vault:admin"]?.label ?? "";
|
|
39
|
+
const lower = label.toLowerCase();
|
|
40
|
+
expect(SCOPE_EXPLANATIONS["vault:admin"]?.level).toBe("admin");
|
|
41
|
+
// Read + write are still part of what admin grants.
|
|
42
|
+
expect(lower).toContain("read");
|
|
43
|
+
expect(lower).toContain("write");
|
|
44
|
+
// The four enumerated admin powers.
|
|
45
|
+
expect(lower).toContain("config");
|
|
46
|
+
expect(lower).toContain("trigger");
|
|
47
|
+
expect(lower).toContain("github");
|
|
48
|
+
expect(lower).toContain("token");
|
|
49
|
+
});
|
|
50
|
+
|
|
32
51
|
test("FIRST_PARTY_SCOPES is sorted and matches the keys of SCOPE_EXPLANATIONS", () => {
|
|
33
52
|
expect(FIRST_PARTY_SCOPES).toEqual([...FIRST_PARTY_SCOPES].sort());
|
|
34
53
|
expect(new Set(FIRST_PARTY_SCOPES)).toEqual(new Set(Object.keys(SCOPE_EXPLANATIONS)));
|
|
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
33
33
|
import { hubFetch } from "../hub-server.ts";
|
|
34
|
+
import { setRootRedirect, setSetting } from "../hub-settings.ts";
|
|
34
35
|
import { writeManifest } from "../services-manifest.ts";
|
|
35
36
|
import { createUser } from "../users.ts";
|
|
36
37
|
|
|
@@ -101,9 +102,7 @@ describe("setup gate (no admin yet)", () => {
|
|
|
101
102
|
test("/login POST still 503s setup_required when no admin exists (hub#644)", async () => {
|
|
102
103
|
const db = openHubDb(hubDbPath(h.dir));
|
|
103
104
|
try {
|
|
104
|
-
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
105
|
-
req("/login", { method: "POST" }),
|
|
106
|
-
);
|
|
105
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/login", { method: "POST" }));
|
|
107
106
|
expect(res.status).toBe(503);
|
|
108
107
|
const body = (await res.json()) as Record<string, unknown>;
|
|
109
108
|
expect(body.error).toBe("setup_required");
|
|
@@ -368,3 +367,112 @@ describe("setup gate (admin exists)", () => {
|
|
|
368
367
|
}
|
|
369
368
|
});
|
|
370
369
|
});
|
|
370
|
+
|
|
371
|
+
describe("configurable bare-`/` redirect target", () => {
|
|
372
|
+
let h: Harness;
|
|
373
|
+
beforeEach(() => {
|
|
374
|
+
h = makeHarness();
|
|
375
|
+
});
|
|
376
|
+
afterEach(() => h.cleanup());
|
|
377
|
+
|
|
378
|
+
/** A set-up hub (admin + vault) so the bare-`/` redirect is reached. */
|
|
379
|
+
function setUpHub(db: ReturnType<typeof openHubDb>): void {
|
|
380
|
+
writeManifest(
|
|
381
|
+
{
|
|
382
|
+
services: [
|
|
383
|
+
{
|
|
384
|
+
name: "parachute-vault",
|
|
385
|
+
version: "0.1.0",
|
|
386
|
+
port: 1940,
|
|
387
|
+
paths: ["/vault/default"],
|
|
388
|
+
health: "/health",
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
join(h.dir, "services.json"),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function handler(db: ReturnType<typeof openHubDb>) {
|
|
397
|
+
return hubFetch(h.dir, { getDb: () => db, manifestPath: join(h.dir, "services.json") });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test("a configured root_redirect retargets the bare-`/` 302", async () => {
|
|
401
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
402
|
+
try {
|
|
403
|
+
await createUser(db, "owner", "pw");
|
|
404
|
+
setUpHub(db);
|
|
405
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
406
|
+
const res = await handler(db)(req("/"));
|
|
407
|
+
expect(res.status).toBe(302);
|
|
408
|
+
expect(res.headers.get("location")).toBe("/surface/reading-room");
|
|
409
|
+
} finally {
|
|
410
|
+
db.close();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("an unsafe stored root_redirect falls back to /admin (never an open redirect)", async () => {
|
|
415
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
416
|
+
try {
|
|
417
|
+
await createUser(db, "owner", "pw");
|
|
418
|
+
setUpHub(db);
|
|
419
|
+
// A hand-edited sqlite row that bypassed write-side validation.
|
|
420
|
+
setSetting(db, "root_redirect", "//evil.com");
|
|
421
|
+
const res = await handler(db)(req("/"));
|
|
422
|
+
expect(res.status).toBe(302);
|
|
423
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
424
|
+
} finally {
|
|
425
|
+
db.close();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("PARACHUTE_HUB_ROOT_REDIRECT env retargets the bare-`/` 302 (no DB row)", async () => {
|
|
430
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
431
|
+
const prev = process.env.PARACHUTE_HUB_ROOT_REDIRECT;
|
|
432
|
+
process.env.PARACHUTE_HUB_ROOT_REDIRECT = "/surface/from-env";
|
|
433
|
+
try {
|
|
434
|
+
await createUser(db, "owner", "pw");
|
|
435
|
+
setUpHub(db);
|
|
436
|
+
const res = await handler(db)(req("/"));
|
|
437
|
+
expect(res.status).toBe(302);
|
|
438
|
+
expect(res.headers.get("location")).toBe("/surface/from-env");
|
|
439
|
+
} finally {
|
|
440
|
+
// Restore process.env to its pre-test state. `delete` (not assign-undefined,
|
|
441
|
+
// which would coerce to the string "undefined") removes a key we added.
|
|
442
|
+
if (prev === undefined) {
|
|
443
|
+
// biome-ignore lint/performance/noDelete: env-key cleanup, not a hot path
|
|
444
|
+
delete process.env.PARACHUTE_HUB_ROOT_REDIRECT;
|
|
445
|
+
} else {
|
|
446
|
+
process.env.PARACHUTE_HUB_ROOT_REDIRECT = prev;
|
|
447
|
+
}
|
|
448
|
+
db.close();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("wizard funnel WINS: a configured root_redirect does NOT bypass setup on a fresh hub", async () => {
|
|
453
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
454
|
+
try {
|
|
455
|
+
// No admin yet → not-set-up hub. Even with a surface configured, the
|
|
456
|
+
// bare-`/` must funnel to the wizard, not a surface that can't work yet.
|
|
457
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
458
|
+
const res = await handler(db)(req("/"));
|
|
459
|
+
expect(res.status).toBe(302);
|
|
460
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
461
|
+
} finally {
|
|
462
|
+
db.close();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("default is unchanged: bare-`/` → /admin when nothing is configured", async () => {
|
|
467
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
468
|
+
try {
|
|
469
|
+
await createUser(db, "owner", "pw");
|
|
470
|
+
setUpHub(db);
|
|
471
|
+
const res = await handler(db)(req("/"));
|
|
472
|
+
expect(res.status).toBe(302);
|
|
473
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
474
|
+
} finally {
|
|
475
|
+
db.close();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -990,6 +990,123 @@ describe("handleSetupGet", () => {
|
|
|
990
990
|
db.close();
|
|
991
991
|
}
|
|
992
992
|
});
|
|
993
|
+
|
|
994
|
+
// hub#618: gate the JSON `?op=` op-snapshot once setup is complete.
|
|
995
|
+
// Mid-setup it stays OPEN (the unauth CLI wizard + brand-new-operator
|
|
996
|
+
// browser both poll it before any session exists); post-complete it
|
|
997
|
+
// requires a session or loopback (it's a post-setup admin surface, and
|
|
998
|
+
// `/admin/setup` is always lockout-exempt so it's otherwise unauth-reachable).
|
|
999
|
+
|
|
1000
|
+
test("mid-setup unauth ?op= still returns the op snapshot (hub#618 regression guard)", async () => {
|
|
1001
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1002
|
+
try {
|
|
1003
|
+
// No admin yet → setup INCOMPLETE → the surface stays open.
|
|
1004
|
+
const reg = getDefaultOperationsRegistry();
|
|
1005
|
+
const op = reg.create("install", "vault");
|
|
1006
|
+
reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/vault@latest");
|
|
1007
|
+
const res = handleSetupGet(
|
|
1008
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1009
|
+
{
|
|
1010
|
+
db,
|
|
1011
|
+
manifestPath: h.manifestPath,
|
|
1012
|
+
configDir: h.dir,
|
|
1013
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1014
|
+
issuer: "https://hub.example",
|
|
1015
|
+
registry: reg,
|
|
1016
|
+
// No loopback flag, no session — the unauth first-boot poll.
|
|
1017
|
+
},
|
|
1018
|
+
);
|
|
1019
|
+
expect(res.status).toBe(200);
|
|
1020
|
+
const body = (await res.json()) as {
|
|
1021
|
+
hasAdmin: boolean;
|
|
1022
|
+
operation?: { id: string; status: string; log: readonly string[] };
|
|
1023
|
+
};
|
|
1024
|
+
expect(body.hasAdmin).toBe(false);
|
|
1025
|
+
expect(body.operation).toBeDefined();
|
|
1026
|
+
expect(body.operation?.id).toBe(op.id);
|
|
1027
|
+
expect(body.operation?.status).toBe("running");
|
|
1028
|
+
} finally {
|
|
1029
|
+
db.close();
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("post-complete unauth ?op= omits the op snapshot; session OR loopback restores it (hub#618)", async () => {
|
|
1034
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1035
|
+
try {
|
|
1036
|
+
// Drive state to COMPLETE: admin + vault + expose mode.
|
|
1037
|
+
const user = await createUser(db, "owner", "pw");
|
|
1038
|
+
writeManifest(
|
|
1039
|
+
{
|
|
1040
|
+
services: [
|
|
1041
|
+
{
|
|
1042
|
+
name: "parachute-vault",
|
|
1043
|
+
version: "0.1.0",
|
|
1044
|
+
port: 1940,
|
|
1045
|
+
paths: ["/vault/default"],
|
|
1046
|
+
health: "/health",
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
},
|
|
1050
|
+
h.manifestPath,
|
|
1051
|
+
);
|
|
1052
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1053
|
+
const reg = getDefaultOperationsRegistry();
|
|
1054
|
+
const op = reg.create("install", "vault");
|
|
1055
|
+
reg.update(op.id, { status: "running" }, "still running");
|
|
1056
|
+
|
|
1057
|
+
const deps = {
|
|
1058
|
+
db,
|
|
1059
|
+
manifestPath: h.manifestPath,
|
|
1060
|
+
configDir: h.dir,
|
|
1061
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1062
|
+
issuer: "https://hub.example",
|
|
1063
|
+
registry: reg,
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// (a) Unauth, non-loopback → operation omitted.
|
|
1067
|
+
const unauth = handleSetupGet(
|
|
1068
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1069
|
+
deps,
|
|
1070
|
+
);
|
|
1071
|
+
expect(unauth.status).toBe(200);
|
|
1072
|
+
const unauthBody = (await unauth.json()) as {
|
|
1073
|
+
hasAdmin: boolean;
|
|
1074
|
+
hasVault: boolean;
|
|
1075
|
+
hasExposeMode: boolean;
|
|
1076
|
+
operation?: unknown;
|
|
1077
|
+
};
|
|
1078
|
+
// Confirm setup actually derived as complete (else the gate is vacuous).
|
|
1079
|
+
expect(unauthBody.hasAdmin).toBe(true);
|
|
1080
|
+
expect(unauthBody.hasVault).toBe(true);
|
|
1081
|
+
expect(unauthBody.hasExposeMode).toBe(true);
|
|
1082
|
+
expect(unauthBody.operation).toBeUndefined();
|
|
1083
|
+
|
|
1084
|
+
// (b) Valid session → operation restored.
|
|
1085
|
+
const { createSession } = await import("../sessions.ts");
|
|
1086
|
+
const session = createSession(db, { userId: user.id });
|
|
1087
|
+
const authed = handleSetupGet(
|
|
1088
|
+
req(`/admin/setup?op=${op.id}`, {
|
|
1089
|
+
headers: {
|
|
1090
|
+
accept: "application/json",
|
|
1091
|
+
cookie: `${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1092
|
+
},
|
|
1093
|
+
}),
|
|
1094
|
+
deps,
|
|
1095
|
+
);
|
|
1096
|
+
const authedBody = (await authed.json()) as { operation?: { id: string } };
|
|
1097
|
+
expect(authedBody.operation?.id).toBe(op.id);
|
|
1098
|
+
|
|
1099
|
+
// (c) Loopback (no session) → operation restored.
|
|
1100
|
+
const loopback = handleSetupGet(
|
|
1101
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1102
|
+
{ ...deps, requestIsLoopback: true },
|
|
1103
|
+
);
|
|
1104
|
+
const loopbackBody = (await loopback.json()) as { operation?: { id: string } };
|
|
1105
|
+
expect(loopbackBody.operation?.id).toBe(op.id);
|
|
1106
|
+
} finally {
|
|
1107
|
+
db.close();
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
993
1110
|
});
|
|
994
1111
|
|
|
995
1112
|
// --- POST /admin/setup/account -------------------------------------------
|
|
@@ -3150,7 +3267,7 @@ describe("typed vault name (hub#267)", () => {
|
|
|
3150
3267
|
}
|
|
3151
3268
|
});
|
|
3152
3269
|
|
|
3153
|
-
test("vault POST with empty name falls back to 'default' +
|
|
3270
|
+
test("vault POST with empty name falls back to 'default' + ALWAYS passes PARACHUTE_VAULT_NAME (#478 Part 2)", async () => {
|
|
3154
3271
|
const db = openHubDb(hubDbPath(h.dir));
|
|
3155
3272
|
try {
|
|
3156
3273
|
const user = await createUser(db, "owner", "pw");
|
|
@@ -3213,12 +3330,12 @@ describe("typed vault name (hub#267)", () => {
|
|
|
3213
3330
|
await new Promise((r) => setTimeout(r, 50));
|
|
3214
3331
|
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
3215
3332
|
expect(vaultSpawn).toBeDefined();
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
3219
|
-
//
|
|
3220
|
-
//
|
|
3221
|
-
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).
|
|
3333
|
+
// #478 Part 2: wizard ALWAYS passes PARACHUTE_VAULT_NAME so vault's
|
|
3334
|
+
// first-boot knows which vault to create once silent auto-create is
|
|
3335
|
+
// removed. Even for the default name, the env var must be set to
|
|
3336
|
+
// "default" — not omitted. PORT is set by the supervisor (hub#356)
|
|
3337
|
+
// for every supervised child regardless.
|
|
3338
|
+
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBe("default");
|
|
3222
3339
|
expect(vaultSpawn?.env?.PORT).toBe("1940");
|
|
3223
3340
|
} finally {
|
|
3224
3341
|
db.close();
|
|
@@ -1591,6 +1591,31 @@ describe("Supervisor port-readiness + structured start-error (§6.5)", () => {
|
|
|
1591
1591
|
expect(spawner.calls).toHaveLength(0);
|
|
1592
1592
|
});
|
|
1593
1593
|
|
|
1594
|
+
test("(#634) preflight non-executable binary → non_executable start-error, NO spawn", async () => {
|
|
1595
|
+
const spawner = makeQueueSpawner();
|
|
1596
|
+
const sup = new Supervisor({
|
|
1597
|
+
spawnFn: spawner.spawn,
|
|
1598
|
+
killFn: noopKill,
|
|
1599
|
+
// `which` requires X_OK so it returns null for a 100644 bin...
|
|
1600
|
+
which: () => null,
|
|
1601
|
+
// ...but the secondary probe finds it present-but-non-executable.
|
|
1602
|
+
findNonExecutable: () => "/x/vault/bin/parachute-vault",
|
|
1603
|
+
portListening: async () => true,
|
|
1604
|
+
startReadyMs: 50,
|
|
1605
|
+
sleep: () => Promise.resolve(),
|
|
1606
|
+
});
|
|
1607
|
+
const state = await sup.start(reqWithPort("vault", 1940));
|
|
1608
|
+
|
|
1609
|
+
expect(state.status).toBe("crashed");
|
|
1610
|
+
expect(state.startError?.error_type).toBe("non_executable");
|
|
1611
|
+
expect(state.startError?.error_description).toContain(
|
|
1612
|
+
"but is not executable — run chmod +x /x/vault/bin/parachute-vault",
|
|
1613
|
+
);
|
|
1614
|
+
// No misleading "not installed" install card, and never spawned.
|
|
1615
|
+
expect(state.startError?.binary).toBe("parachute-vault");
|
|
1616
|
+
expect(spawner.calls).toHaveLength(0);
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1594
1619
|
test("a clean re-start clears a prior started-but-unbound start-error", async () => {
|
|
1595
1620
|
const first = makeFakeProc(201);
|
|
1596
1621
|
const second = makeFakeProc(202);
|
|
@@ -68,11 +68,16 @@ describe("listVaultNames", () => {
|
|
|
68
68
|
expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
test("
|
|
71
|
+
test("#478: bare empty-paths `parachute-vault` row yields NO name (no phantom 'default')", () => {
|
|
72
|
+
// What vault's self-register emits at zero vaults: the row stays present
|
|
73
|
+
// (installed-detection) but advertises no `/vault/<name>` path. It must not
|
|
74
|
+
// leak a selectable/assignable "default" before any vault exists. Mirrors
|
|
75
|
+
// the empty-paths skip in admin-vaults.ts (findExistingVault /
|
|
76
|
+
// listVaultInstanceNames).
|
|
72
77
|
const manifest: ServicesManifest = {
|
|
73
78
|
services: [
|
|
74
79
|
{
|
|
75
|
-
name: "parachute-vault
|
|
80
|
+
name: "parachute-vault",
|
|
76
81
|
port: 1940,
|
|
77
82
|
paths: [],
|
|
78
83
|
health: "/h",
|
|
@@ -80,7 +85,31 @@ describe("listVaultNames", () => {
|
|
|
80
85
|
},
|
|
81
86
|
],
|
|
82
87
|
};
|
|
83
|
-
expect(listVaultNames(manifest)).toEqual([
|
|
88
|
+
expect(listVaultNames(manifest)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("#478: empty-paths row is skipped; real-paths vault rows are unchanged (positive control)", () => {
|
|
92
|
+
// An empty-paths row alongside a real-paths row: the real instance still
|
|
93
|
+
// resolves, the empty one contributes nothing.
|
|
94
|
+
const manifest: ServicesManifest = {
|
|
95
|
+
services: [
|
|
96
|
+
{
|
|
97
|
+
name: "parachute-vault",
|
|
98
|
+
port: 1940,
|
|
99
|
+
paths: [],
|
|
100
|
+
health: "/h",
|
|
101
|
+
version: "0.1.0",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "parachute-vault-work",
|
|
105
|
+
port: 1941,
|
|
106
|
+
paths: ["/vault/work"],
|
|
107
|
+
health: "/h",
|
|
108
|
+
version: "0.1.0",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
expect(listVaultNames(manifest)).toEqual(["work"]);
|
|
84
113
|
});
|
|
85
114
|
|
|
86
115
|
test("deduplicates collisions across single-entry + per-vault shapes", () => {
|
|
@@ -83,6 +83,7 @@ const SUCCESS_BODY = {
|
|
|
83
83
|
grants_dropped: 2,
|
|
84
84
|
user_vaults_removed: 4,
|
|
85
85
|
invites_invalidated: 1,
|
|
86
|
+
vault_cap_removed: true,
|
|
86
87
|
connections_torn_down: 1,
|
|
87
88
|
orphaned_channels: [],
|
|
88
89
|
vault_removed: true,
|
|
@@ -156,6 +157,7 @@ describe("vaultRemove — 200 success", () => {
|
|
|
156
157
|
expect(text).toContain("3");
|
|
157
158
|
expect(text).toContain("user_vaults removed:");
|
|
158
159
|
expect(text).toContain("4");
|
|
160
|
+
expect(text).toContain("storage cap removed:");
|
|
159
161
|
expect(text).toContain("vault removed:");
|
|
160
162
|
});
|
|
161
163
|
|
|
@@ -194,19 +196,35 @@ describe("vaultRemove — 200 success", () => {
|
|
|
194
196
|
});
|
|
195
197
|
});
|
|
196
198
|
|
|
197
|
-
describe("vaultRemove —
|
|
198
|
-
test("
|
|
199
|
+
describe("vaultRemove — last vault (#678: cascade-then-delete, no 409)", () => {
|
|
200
|
+
test("the last vault deletes via the cascade (200) and NEVER spawns parachute-vault directly", async () => {
|
|
199
201
|
await withSpawnSpy(async (spawned) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
// The endpoint no longer refuses the last vault — it returns 200 with the
|
|
203
|
+
// cascade summary, identical to any other delete. The CLI just renders it.
|
|
204
|
+
const lastVaultBody = {
|
|
205
|
+
ok: true,
|
|
206
|
+
name: "scratch",
|
|
207
|
+
cascade: {
|
|
208
|
+
tokens_revoked: 2,
|
|
209
|
+
grants_rewritten: 0,
|
|
210
|
+
grants_dropped: 1,
|
|
211
|
+
user_vaults_removed: 1,
|
|
212
|
+
invites_invalidated: 0,
|
|
213
|
+
vault_cap_removed: true,
|
|
214
|
+
connections_torn_down: 0,
|
|
215
|
+
orphaned_channels: [],
|
|
216
|
+
vault_removed: true,
|
|
217
|
+
module_restarted: true,
|
|
208
218
|
},
|
|
209
|
-
|
|
219
|
+
warnings: [
|
|
220
|
+
{
|
|
221
|
+
step: "last_vault",
|
|
222
|
+
detail:
|
|
223
|
+
"the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: lastVaultBody }]);
|
|
210
228
|
const sinks = makeSinks();
|
|
211
229
|
const code = await vaultRemove(["scratch"], {
|
|
212
230
|
resolveBearer: async () => BEARER,
|
|
@@ -214,17 +232,20 @@ describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
|
|
|
214
232
|
log: sinks.log,
|
|
215
233
|
logError: sinks.logError,
|
|
216
234
|
});
|
|
217
|
-
//
|
|
218
|
-
expect(code).
|
|
219
|
-
// Exactly ONE fetch (the DELETE) —
|
|
235
|
+
// 200 → success exit; the cascade did its work.
|
|
236
|
+
expect(code).toBe(0);
|
|
237
|
+
// Exactly ONE fetch (the DELETE) — the cascade runs server-side over loopback.
|
|
220
238
|
expect(calls).toHaveLength(1);
|
|
221
239
|
expect(calls[0]?.method).toBe("DELETE");
|
|
222
|
-
// The load-bearing invariant:
|
|
240
|
+
// The load-bearing invariant still holds: the CLI never spawns
|
|
241
|
+
// `parachute-vault` itself — destruction goes through the hub endpoint.
|
|
223
242
|
expect(spawned.count).toBe(0);
|
|
224
|
-
//
|
|
225
|
-
const
|
|
226
|
-
expect(
|
|
227
|
-
expect(
|
|
243
|
+
// The cascade summary renders, including the last_vault heads-up warning.
|
|
244
|
+
const text = sinks.text();
|
|
245
|
+
expect(text).toContain("tokens revoked:");
|
|
246
|
+
expect(text).toContain("vault removed:");
|
|
247
|
+
expect(text).toContain("last_vault");
|
|
248
|
+
expect(text).toContain("auto_create: false");
|
|
228
249
|
});
|
|
229
250
|
});
|
|
230
251
|
});
|
|
@@ -472,13 +472,48 @@ describe("buildWellKnown", () => {
|
|
|
472
472
|
);
|
|
473
473
|
});
|
|
474
474
|
|
|
475
|
-
test("
|
|
475
|
+
test("an empty-paths VAULT row is skipped entirely — no phantom default (#478)", () => {
|
|
476
|
+
// A vault services row with `paths: []` means "module installed but no
|
|
477
|
+
// servable vault instance" (vault's self-register emits this at zero
|
|
478
|
+
// vaults). It must NOT fabricate a vault entry at root in either the
|
|
479
|
+
// `vaults` array or the flat `services` catalog. Mirrors the empty-paths
|
|
480
|
+
// skip in admin-vaults.ts / vault-names.ts / oauth-handlers.ts.
|
|
476
481
|
const entry: ServiceEntry = { ...vault, paths: [] };
|
|
477
482
|
const doc = buildWellKnown({
|
|
478
483
|
services: [entry],
|
|
479
484
|
canonicalOrigin: "https://x.example",
|
|
480
485
|
});
|
|
481
|
-
expect(doc.vaults
|
|
486
|
+
expect(doc.vaults).toEqual([]);
|
|
487
|
+
// The row contributes nothing to the flat services list either — no
|
|
488
|
+
// phantom `/` mount advertised.
|
|
489
|
+
expect(doc.services).toEqual([]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("positive control: a vault row WITH a path still emits its vault + services entries (#478)", () => {
|
|
493
|
+
const doc = buildWellKnown({
|
|
494
|
+
services: [{ ...vault, paths: ["/vault/default"] }],
|
|
495
|
+
canonicalOrigin: "https://x.example",
|
|
496
|
+
});
|
|
497
|
+
expect(doc.vaults).toEqual([
|
|
498
|
+
{
|
|
499
|
+
name: "default",
|
|
500
|
+
url: "https://x.example/vault/default",
|
|
501
|
+
version: "0.2.4",
|
|
502
|
+
},
|
|
503
|
+
]);
|
|
504
|
+
expect(doc.services.map((s) => s.name)).toEqual(["parachute-vault"]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("a NON-vault row with empty paths still falls back to / (#478 scope guard)", () => {
|
|
508
|
+
// The empty-paths skip is vault-only. A non-vault service legitimately
|
|
509
|
+
// mounts at root when path-less — that behavior is unchanged.
|
|
510
|
+
const entry: ServiceEntry = { ...notes, paths: [] };
|
|
511
|
+
const doc = buildWellKnown({
|
|
512
|
+
services: [entry],
|
|
513
|
+
canonicalOrigin: "https://x.example",
|
|
514
|
+
});
|
|
515
|
+
expect(doc.services.map((s) => s.path)).toEqual(["/"]);
|
|
516
|
+
expect(doc.notes).toEqual([{ url: "https://x.example/", version: "0.0.1" }]);
|
|
482
517
|
});
|
|
483
518
|
|
|
484
519
|
// Hierarchical sub-units (hub#313 — parachute-app design doc §12). Each
|