@openparachute/hub 0.6.5-rc.8 → 0.7.0
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 +34 -0
- 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.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-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-server.ts +349 -59
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- 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/web/ui/dist/assets/index-C-XzMVqN.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
|
@@ -3158,6 +3158,54 @@ describe("typed vault name (hub#267)", () => {
|
|
|
3158
3158
|
}
|
|
3159
3159
|
});
|
|
3160
3160
|
|
|
3161
|
+
test("vault POST rejects every consolidated reserved name (B2h: list, new, assets, admin)", async () => {
|
|
3162
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
3163
|
+
try {
|
|
3164
|
+
const user = await createUser(db, "owner", "pw");
|
|
3165
|
+
const { createSession } = await import("../sessions.ts");
|
|
3166
|
+
const session = createSession(db, { userId: user.id });
|
|
3167
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
3168
|
+
db,
|
|
3169
|
+
manifestPath: h.manifestPath,
|
|
3170
|
+
configDir: h.dir,
|
|
3171
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
3172
|
+
issuer: "https://hub.example",
|
|
3173
|
+
registry: getDefaultOperationsRegistry(),
|
|
3174
|
+
});
|
|
3175
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
3176
|
+
for (const name of ["list", "new", "assets", "admin"]) {
|
|
3177
|
+
const post = await handleSetupVaultPost(
|
|
3178
|
+
req("/admin/setup/vault", {
|
|
3179
|
+
method: "POST",
|
|
3180
|
+
body: new URLSearchParams({
|
|
3181
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
3182
|
+
vault_name: name,
|
|
3183
|
+
}).toString(),
|
|
3184
|
+
headers: {
|
|
3185
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3186
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
3187
|
+
},
|
|
3188
|
+
}),
|
|
3189
|
+
{
|
|
3190
|
+
db,
|
|
3191
|
+
manifestPath: h.manifestPath,
|
|
3192
|
+
configDir: h.dir,
|
|
3193
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
3194
|
+
issuer: "https://hub.example",
|
|
3195
|
+
supervisor: makeSupervisor(),
|
|
3196
|
+
registry: getDefaultOperationsRegistry(),
|
|
3197
|
+
},
|
|
3198
|
+
);
|
|
3199
|
+
expect(post.status).toBe(400);
|
|
3200
|
+
const html = await post.text();
|
|
3201
|
+
expect(html).toContain("reserved");
|
|
3202
|
+
expect(getSetting(db, "setup_vault_name")).toBeUndefined();
|
|
3203
|
+
}
|
|
3204
|
+
} finally {
|
|
3205
|
+
db.close();
|
|
3206
|
+
}
|
|
3207
|
+
});
|
|
3208
|
+
|
|
3161
3209
|
test("vault POST with empty name falls back to 'default' + omits the env override", async () => {
|
|
3162
3210
|
const db = openHubDb(hubDbPath(h.dir));
|
|
3163
3211
|
try {
|
|
@@ -4350,19 +4398,198 @@ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
|
|
|
4350
4398
|
expect(body.step).toBe("expose");
|
|
4351
4399
|
// The skip flag is persisted.
|
|
4352
4400
|
expect(getSetting(db, "setup_vault_skipped")).toBe("true");
|
|
4353
|
-
// deriveWizardState advances past the vault step
|
|
4401
|
+
// deriveWizardState advances past the vault step — but hasRealVault
|
|
4402
|
+
// stays false (no instance exists; only the skip marker is set). The
|
|
4403
|
+
// distinction is what the re-enterable vault step (B5) keys on.
|
|
4354
4404
|
const s = deriveWizardState({
|
|
4355
4405
|
db,
|
|
4356
4406
|
manifestPath: h.manifestPath,
|
|
4357
4407
|
readExposeStateFn: h.readExposeStateFn,
|
|
4358
4408
|
});
|
|
4359
4409
|
expect(s.hasVault).toBe(true);
|
|
4410
|
+
expect(s.hasRealVault).toBe(false);
|
|
4360
4411
|
expect(s.step).toBe("expose");
|
|
4361
4412
|
} finally {
|
|
4362
4413
|
db.close();
|
|
4363
4414
|
}
|
|
4364
4415
|
});
|
|
4365
4416
|
|
|
4417
|
+
// --- B5 (2026-06-09 hub-module-boundary): re-enterable vault step --------
|
|
4418
|
+
//
|
|
4419
|
+
// A wizard-skip leaves the vault module installed with zero instances and
|
|
4420
|
+
// `setup_vault_skipped` satisfying hasVault — pre-B5 the create form was
|
|
4421
|
+
// unreachable forever after. The hub-side "create your first vault"
|
|
4422
|
+
// affordances (Home's vault card, the legacy /admin/vaults empty state)
|
|
4423
|
+
// deep-link `/admin/setup?step=vault`, which must re-enter the form.
|
|
4424
|
+
|
|
4425
|
+
test("GET ?step=vault re-enters the create form after a wizard-skip (session-gated)", async () => {
|
|
4426
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4427
|
+
try {
|
|
4428
|
+
await createUser(db, "owner", "pw");
|
|
4429
|
+
setSetting(db, "setup_vault_skipped", "true");
|
|
4430
|
+
const { createSession } = await import("../sessions.ts");
|
|
4431
|
+
const user = getUserByUsername(db, "owner");
|
|
4432
|
+
if (!user) throw new Error("user missing");
|
|
4433
|
+
const session = createSession(db, { userId: user.id });
|
|
4434
|
+
const res = handleSetupGet(
|
|
4435
|
+
req("/admin/setup?step=vault", {
|
|
4436
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
4437
|
+
}),
|
|
4438
|
+
{
|
|
4439
|
+
db,
|
|
4440
|
+
manifestPath: h.manifestPath,
|
|
4441
|
+
configDir: h.dir,
|
|
4442
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4443
|
+
issuer: "http://127.0.0.1:1939",
|
|
4444
|
+
registry: getDefaultOperationsRegistry(),
|
|
4445
|
+
},
|
|
4446
|
+
);
|
|
4447
|
+
expect(res.status).toBe(200);
|
|
4448
|
+
const html = await res.text();
|
|
4449
|
+
// The vault create/import/skip form — not the expose step the plain
|
|
4450
|
+
// GET would resume at post-skip.
|
|
4451
|
+
expect(html).toContain('action="/admin/setup/vault"');
|
|
4452
|
+
} finally {
|
|
4453
|
+
db.close();
|
|
4454
|
+
}
|
|
4455
|
+
});
|
|
4456
|
+
|
|
4457
|
+
test("GET ?step=vault without a session 302s to /login (post-skip)", async () => {
|
|
4458
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4459
|
+
try {
|
|
4460
|
+
await createUser(db, "owner", "pw");
|
|
4461
|
+
setSetting(db, "setup_vault_skipped", "true");
|
|
4462
|
+
const res = handleSetupGet(req("/admin/setup?step=vault"), {
|
|
4463
|
+
db,
|
|
4464
|
+
manifestPath: h.manifestPath,
|
|
4465
|
+
configDir: h.dir,
|
|
4466
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4467
|
+
issuer: "http://127.0.0.1:1939",
|
|
4468
|
+
registry: getDefaultOperationsRegistry(),
|
|
4469
|
+
});
|
|
4470
|
+
expect(res.status).toBe(302);
|
|
4471
|
+
expect(res.headers.get("location")).toBe(
|
|
4472
|
+
`/login?next=${encodeURIComponent("/admin/setup?step=vault")}`,
|
|
4473
|
+
);
|
|
4474
|
+
} finally {
|
|
4475
|
+
db.close();
|
|
4476
|
+
}
|
|
4477
|
+
});
|
|
4478
|
+
|
|
4479
|
+
test("GET ?step=vault with NO admin falls through to the welcome step (hasAdmin guard)", async () => {
|
|
4480
|
+
// Fresh box, no user rows: the re-entry branch is gated on
|
|
4481
|
+
// `state.hasAdmin` — the param must not jump a brand-new operator past
|
|
4482
|
+
// account creation into a provisioning form (the POST would reject the
|
|
4483
|
+
// session-less submit anyway, but the GET shouldn't render out of order
|
|
4484
|
+
// either).
|
|
4485
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4486
|
+
try {
|
|
4487
|
+
const res = handleSetupGet(req("/admin/setup?step=vault"), {
|
|
4488
|
+
db,
|
|
4489
|
+
manifestPath: h.manifestPath,
|
|
4490
|
+
configDir: h.dir,
|
|
4491
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4492
|
+
issuer: "http://127.0.0.1:1939",
|
|
4493
|
+
registry: getDefaultOperationsRegistry(),
|
|
4494
|
+
});
|
|
4495
|
+
expect(res.status).toBe(200);
|
|
4496
|
+
// The welcome/account form — not the vault create form, not a redirect.
|
|
4497
|
+
const html = await res.text();
|
|
4498
|
+
expect(html).toContain('action="/admin/setup/account"');
|
|
4499
|
+
expect(html).not.toContain('action="/admin/setup/vault"');
|
|
4500
|
+
} finally {
|
|
4501
|
+
db.close();
|
|
4502
|
+
}
|
|
4503
|
+
});
|
|
4504
|
+
|
|
4505
|
+
test("GET ?step=vault is ignored when a real vault instance exists", async () => {
|
|
4506
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4507
|
+
try {
|
|
4508
|
+
await createUser(db, "owner", "pw");
|
|
4509
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
4510
|
+
writeManifest(
|
|
4511
|
+
{
|
|
4512
|
+
services: [
|
|
4513
|
+
{
|
|
4514
|
+
name: "parachute-vault",
|
|
4515
|
+
version: "0.1.0",
|
|
4516
|
+
port: 1940,
|
|
4517
|
+
paths: ["/vault/default"],
|
|
4518
|
+
health: "/health",
|
|
4519
|
+
},
|
|
4520
|
+
],
|
|
4521
|
+
},
|
|
4522
|
+
h.manifestPath,
|
|
4523
|
+
);
|
|
4524
|
+
const res = handleSetupGet(req("/admin/setup?step=vault"), {
|
|
4525
|
+
db,
|
|
4526
|
+
manifestPath: h.manifestPath,
|
|
4527
|
+
configDir: h.dir,
|
|
4528
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4529
|
+
issuer: "http://127.0.0.1:1939",
|
|
4530
|
+
registry: getDefaultOperationsRegistry(),
|
|
4531
|
+
});
|
|
4532
|
+
// Setup is fully complete — the param must not reopen a provisioning
|
|
4533
|
+
// form; the normal completed flow (301 → /login) runs instead.
|
|
4534
|
+
expect(res.status).toBe(301);
|
|
4535
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
4536
|
+
} finally {
|
|
4537
|
+
db.close();
|
|
4538
|
+
}
|
|
4539
|
+
});
|
|
4540
|
+
|
|
4541
|
+
test("vault POST mode=create proceeds after a skip (short-circuit keys on hasRealVault)", async () => {
|
|
4542
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4543
|
+
try {
|
|
4544
|
+
await createUser(db, "owner", "pw");
|
|
4545
|
+
setSetting(db, "setup_vault_skipped", "true");
|
|
4546
|
+
// No supervisor in deps: a create that gets PAST the short-circuit hits
|
|
4547
|
+
// the supervisor gate and 503s. Pre-B5 this returned 200
|
|
4548
|
+
// `{ step: "expose", message: "vault already provisioned" }` because the
|
|
4549
|
+
// skip marker satisfied hasVault — the form was a dead end.
|
|
4550
|
+
const baseDeps = {
|
|
4551
|
+
db,
|
|
4552
|
+
manifestPath: h.manifestPath,
|
|
4553
|
+
configDir: h.dir,
|
|
4554
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4555
|
+
issuer: "http://127.0.0.1:1939",
|
|
4556
|
+
registry: getDefaultOperationsRegistry(),
|
|
4557
|
+
};
|
|
4558
|
+
const getRes = handleSetupGet(
|
|
4559
|
+
req("/admin/setup", { headers: { accept: "application/json" } }),
|
|
4560
|
+
baseDeps,
|
|
4561
|
+
);
|
|
4562
|
+
const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
|
|
4563
|
+
const envelope = (await getRes.json()) as { csrfToken: string };
|
|
4564
|
+
const { createSession } = await import("../sessions.ts");
|
|
4565
|
+
const user = getUserByUsername(db, "owner");
|
|
4566
|
+
if (!user) throw new Error("user missing");
|
|
4567
|
+
const session = createSession(db, { userId: user.id });
|
|
4568
|
+
const cookieHeader = `${SESSION_COOKIE_NAME}=${session.id}; ${CSRF_COOKIE_NAME}=${csrf}`;
|
|
4569
|
+
const postRes = await handleSetupVaultPost(
|
|
4570
|
+
req("/admin/setup/vault", {
|
|
4571
|
+
method: "POST",
|
|
4572
|
+
headers: {
|
|
4573
|
+
accept: "application/json",
|
|
4574
|
+
"content-type": "application/json",
|
|
4575
|
+
cookie: cookieHeader,
|
|
4576
|
+
},
|
|
4577
|
+
body: JSON.stringify({
|
|
4578
|
+
[CSRF_FIELD_NAME]: envelope.csrfToken,
|
|
4579
|
+
mode: "create",
|
|
4580
|
+
vault_name: "second-chance",
|
|
4581
|
+
}),
|
|
4582
|
+
}),
|
|
4583
|
+
baseDeps,
|
|
4584
|
+
);
|
|
4585
|
+
expect(postRes.status).toBe(503);
|
|
4586
|
+
const body = (await postRes.json()) as { error: string };
|
|
4587
|
+
expect(body.error).toContain("supervisor");
|
|
4588
|
+
} finally {
|
|
4589
|
+
db.close();
|
|
4590
|
+
}
|
|
4591
|
+
});
|
|
4592
|
+
|
|
4366
4593
|
test("vault step import mode requires remote_url (400 on empty)", async () => {
|
|
4367
4594
|
const db = openHubDb(hubDbPath(h.dir));
|
|
4368
4595
|
try {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import { DEFAULT_VAULT_NAME, validateVaultName } from "../vault-name.ts";
|
|
9
|
+
import { DEFAULT_VAULT_NAME, RESERVED_VAULT_NAMES, validateVaultName } from "../vault-name.ts";
|
|
10
10
|
|
|
11
11
|
describe("validateVaultName", () => {
|
|
12
12
|
test("accepts lowercase alphanumeric + hyphens/underscores", () => {
|
|
@@ -64,10 +64,25 @@ describe("validateVaultName", () => {
|
|
|
64
64
|
expect(validateVaultName(" ").ok).toBe(false);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
test("rejects
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
test("rejects every consolidated reserved name (B2h: list, new, assets, admin)", () => {
|
|
68
|
+
// One set for every hub edge — wizard, invite redemption, POST /vaults.
|
|
69
|
+
// `list` mirrors vault's CLI reservation; `new`/`assets` shadow SPA
|
|
70
|
+
// routes; `admin` shadows the daemon-level /vault/admin mount (B-route).
|
|
71
|
+
for (const name of ["list", "new", "assets", "admin"]) {
|
|
72
|
+
const result = validateVaultName(name);
|
|
73
|
+
expect(result.ok).toBe(false);
|
|
74
|
+
if (!result.ok) expect(result.error).toContain("reserved");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("near-miss names of the reserved set still pass (admins, listing, my-admin)", () => {
|
|
79
|
+
for (const name of ["admins", "listing", "my-admin", "assets2", "newer"]) {
|
|
80
|
+
expect(validateVaultName(name).ok).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("RESERVED_VAULT_NAMES is the consolidated four-name set", () => {
|
|
85
|
+
expect([...RESERVED_VAULT_NAMES].sort()).toEqual(["admin", "assets", "list", "new"]);
|
|
71
86
|
});
|
|
72
87
|
|
|
73
88
|
test("DEFAULT_VAULT_NAME is 'default'", () => {
|
|
@@ -361,26 +361,27 @@ describe("buildWellKnown", () => {
|
|
|
361
361
|
expect(notesSvc?.uiUrl).toBe("https://x.example/notes");
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
364
|
+
// B4 unified semantics (2026-06-09 hub-module-boundary): relative
|
|
365
|
+
// (no leading slash) = the per-instance form, mount-joined per vault
|
|
366
|
+
// instance; leading-"/" = origin-absolute pass-through. The literal legacy
|
|
367
|
+
// `"/admin/"` on a vault entry rides the one-release COMPAT SHIM
|
|
368
|
+
// (mount-join + deprecation log) because deployed vaults still declare it.
|
|
369
|
+
test("vault RELATIVE uiUrl mount-joins per instance (B4 per-instance form)", () => {
|
|
369
370
|
const doc = buildWellKnown({
|
|
370
371
|
services: [vault],
|
|
371
372
|
canonicalOrigin: "https://x.example",
|
|
372
|
-
uiUrlFor: () => "
|
|
373
|
+
uiUrlFor: () => "admin/",
|
|
373
374
|
});
|
|
374
375
|
const svc = doc.services.find((s) => s.name === "parachute-vault");
|
|
375
376
|
expect(svc?.uiUrl).toBe("https://x.example/vault/default/admin/");
|
|
376
377
|
});
|
|
377
378
|
|
|
378
|
-
test("vault uiUrl
|
|
379
|
+
test("vault RELATIVE uiUrl mount-joins per instance for multi-path vault entries", () => {
|
|
379
380
|
const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
|
|
380
381
|
const doc = buildWellKnown({
|
|
381
382
|
services: [multi],
|
|
382
383
|
canonicalOrigin: "https://x.example",
|
|
383
|
-
uiUrlFor: () => "
|
|
384
|
+
uiUrlFor: () => "admin/",
|
|
384
385
|
});
|
|
385
386
|
const rows = doc.services.filter((s) => s.name === "parachute-vault");
|
|
386
387
|
expect(rows.length).toBe(2);
|
|
@@ -389,6 +390,41 @@ describe("buildWellKnown", () => {
|
|
|
389
390
|
expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
|
|
390
391
|
});
|
|
391
392
|
|
|
393
|
+
test('COMPAT SHIM: vault legacy "/admin/" uiUrl still mount-joins per instance (one release)', () => {
|
|
394
|
+
// Deployed vaults declare `uiUrl: "/admin/"` (the OLD per-instance form).
|
|
395
|
+
// Origin-absolute resolution would point every tile at the daemon-level
|
|
396
|
+
// /vault/admin mount — so the literal "/admin"/"/admin/" keeps the old
|
|
397
|
+
// mount-join for one release, with a deprecation log. Remove the shim
|
|
398
|
+
// once vault's new manifest ("admin/") reaches @latest.
|
|
399
|
+
const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
|
|
400
|
+
const doc = buildWellKnown({
|
|
401
|
+
services: [multi],
|
|
402
|
+
canonicalOrigin: "https://x.example",
|
|
403
|
+
uiUrlFor: () => "/admin/",
|
|
404
|
+
});
|
|
405
|
+
const rows = doc.services.filter((s) => s.name === "parachute-vault");
|
|
406
|
+
const uiUrls = rows.map((r) => r.uiUrl).sort();
|
|
407
|
+
expect(uiUrls[0]).toBe("https://x.example/vault/default/admin/");
|
|
408
|
+
expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("vault LEADING-SLASH uiUrl (non-shim) is origin-absolute pass-through (B4)", () => {
|
|
412
|
+
// The daemon-level surface form: `/vault/admin/` resolves against the
|
|
413
|
+
// origin — NOT per-instance — so a multi-path vault emits the same
|
|
414
|
+
// daemon-level URL on each row.
|
|
415
|
+
const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
|
|
416
|
+
const doc = buildWellKnown({
|
|
417
|
+
services: [multi],
|
|
418
|
+
canonicalOrigin: "https://x.example",
|
|
419
|
+
uiUrlFor: () => "/vault/admin/",
|
|
420
|
+
});
|
|
421
|
+
const rows = doc.services.filter((s) => s.name === "parachute-vault");
|
|
422
|
+
expect(rows.length).toBe(2);
|
|
423
|
+
for (const r of rows) {
|
|
424
|
+
expect(r.uiUrl).toBe("https://x.example/vault/admin/");
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
392
428
|
test("vault uiUrl absolute URL still passes through verbatim (no prefix)", () => {
|
|
393
429
|
const doc = buildWellKnown({
|
|
394
430
|
services: [vault],
|
|
@@ -77,10 +77,12 @@ export interface AccountVaultAdminTokenDeps {
|
|
|
77
77
|
hubOrigin: string;
|
|
78
78
|
/**
|
|
79
79
|
* The vault's declared `managementUrl` (from its `.parachute/module.json`),
|
|
80
|
-
* resolved by the route handler at request time.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* admin
|
|
80
|
+
* resolved by the route handler at request time. Resolved per the B4
|
|
81
|
+
* unified semantics (http(s):// verbatim · leading-`/` origin-absolute ·
|
|
82
|
+
* relative joined under the vault's mounted URL; the literal legacy
|
|
83
|
+
* `"/admin/"` mount-joins via the one-release compat shim). Defaults to
|
|
84
|
+
* `"admin/"` (vault's canonical per-instance value) when the handler can't
|
|
85
|
+
* resolve one — that's where the admin sibling's deep-link lands too.
|
|
84
86
|
*/
|
|
85
87
|
managementUrl?: string;
|
|
86
88
|
/** Test seam for the clock (mint). */
|
|
@@ -94,17 +96,43 @@ function htmlResponse(body: string, status = 200, extra: Record<string, string>
|
|
|
94
96
|
});
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/** One-time deprecation log for the legacy `"/admin/"` managementUrl (B4 compat shim). */
|
|
100
|
+
let warnedLegacyManagementUrl = false;
|
|
101
|
+
|
|
97
102
|
/**
|
|
98
103
|
* Resolve a vault's `managementUrl` against the vault's hub-mounted URL.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree
|
|
104
|
+
* Unified URL-resolution semantics (B4 of the 2026-06-09 hub-module-boundary
|
|
105
|
+
* migration) — mirrors `resolveManagementUrl` in the SPA's
|
|
106
|
+
* `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree:
|
|
107
|
+
*
|
|
108
|
+
* - Absolute http(s) URL → verbatim.
|
|
109
|
+
* - Leading-`/` path → ORIGIN-ABSOLUTE: resolved against the vault URL's
|
|
110
|
+
* origin (not joined under the vault mount).
|
|
111
|
+
* - Relative path (no leading slash, e.g. `"admin/"`) → the PER-INSTANCE
|
|
112
|
+
* form: joined under the vault's mounted URL
|
|
113
|
+
* (`<origin>/vault/<name>/admin/`).
|
|
114
|
+
*
|
|
115
|
+
* COMPAT SHIM (one release — remove once vault's new manifest reaches
|
|
116
|
+
* @latest): the literal legacy `"/admin"`/`"/admin/"` is the OLD per-instance
|
|
117
|
+
* relative declaration deployed vaults still ship; it joins under the vault
|
|
118
|
+
* URL (the pre-B4 behavior) with a one-time deprecation log.
|
|
102
119
|
*/
|
|
103
120
|
function resolveManagementUrl(vaultUrl: string, managementUrl: string): string {
|
|
104
121
|
if (/^https?:\/\//i.test(managementUrl)) return managementUrl;
|
|
105
122
|
const base = vaultUrl.replace(/\/+$/, "");
|
|
106
|
-
|
|
107
|
-
|
|
123
|
+
if (managementUrl === "/admin" || managementUrl === "/admin/") {
|
|
124
|
+
if (!warnedLegacyManagementUrl) {
|
|
125
|
+
warnedLegacyManagementUrl = true;
|
|
126
|
+
console.warn(
|
|
127
|
+
`account-vault-admin-token: vault declares the legacy per-instance managementUrl ${JSON.stringify(managementUrl)}; joining under the vault URL for one release. New semantics: relative ("admin/") = per-instance join, leading-"/" = origin-absolute. Upgrade the vault module to clear this.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return `${base}${managementUrl}`;
|
|
131
|
+
}
|
|
132
|
+
if (managementUrl.startsWith("/")) {
|
|
133
|
+
return new URL(managementUrl, `${base}/`).toString();
|
|
134
|
+
}
|
|
135
|
+
return `${base}/${managementUrl}`;
|
|
108
136
|
}
|
|
109
137
|
|
|
110
138
|
export async function handleAccountVaultAdminTokenPost(
|
|
@@ -224,14 +252,15 @@ export async function handleAccountVaultAdminTokenPost(
|
|
|
224
252
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
225
253
|
});
|
|
226
254
|
|
|
227
|
-
// Build the redirect target: <vault-url
|
|
255
|
+
// Build the redirect target: <vault-url>/<managementUrl>#token=<jwt>. The
|
|
228
256
|
// vault URL is the hub-mounted path (`<hubOrigin>/vault/<name>`); the
|
|
229
|
-
// managementUrl (default
|
|
230
|
-
//
|
|
231
|
-
//
|
|
257
|
+
// managementUrl (default `"admin/"` — the per-instance relative form under
|
|
258
|
+
// the B4 semantics) is the vault admin SPA entry point. The JWT rides the
|
|
259
|
+
// URL fragment — never sent to the server — exactly as the hub SPA's
|
|
260
|
+
// "Manage" button does (vault PR #219).
|
|
232
261
|
const trimmedOrigin = deps.hubOrigin.replace(/\/+$/, "");
|
|
233
262
|
const vaultUrl = `${trimmedOrigin}/vault/${vaultName}`;
|
|
234
|
-
const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "
|
|
263
|
+
const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "admin/");
|
|
235
264
|
const sep = target.includes("#") ? "&" : "#";
|
|
236
265
|
const location = `${target}${sep}token=${minted.token}`;
|
|
237
266
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /admin/channel-token` — exchange a valid admin session cookie for a
|
|
3
|
+
* short-lived JWT carrying `channel:read channel:send channel:admin`.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: two channel-owned UIs, both served behind hub's proxy to a
|
|
6
|
+
* logged-in portal operator, need a Bearer to talk to channel's API the same
|
|
7
|
+
* way the vault-management and scribe-config SPAs do, without running the
|
|
8
|
+
* public `/oauth/authorize` flow:
|
|
9
|
+
* - The **chat UI** (`/channel/ui`) receives replies over SSE
|
|
10
|
+
* (`channel:read`) and posts a message (`channel:send`).
|
|
11
|
+
* - The **config/admin UI** (`/channel/admin`, the 2026-06-09 modular-UI
|
|
12
|
+
* architecture P3/P4 config surface) lists + edits configured channels via
|
|
13
|
+
* `channel:admin`-gated endpoints (`requireScope(SCOPE_ADMIN)` in channel's
|
|
14
|
+
* daemon).
|
|
15
|
+
*
|
|
16
|
+
* Both UIs fetch this single endpoint (`fetchToken()` against
|
|
17
|
+
* `/admin/channel-token`), so the minted token carries the union of the scopes
|
|
18
|
+
* either UI needs. The chat UI simply ignores the extra `channel:admin` scope;
|
|
19
|
+
* `requireScope` checks for the *presence* of a specific scope, so extra
|
|
20
|
+
* scopes never break a read/send call. This is what makes the channel config
|
|
21
|
+
* UI work without re-touching the channel repo — the hub endpoint the config
|
|
22
|
+
* UI already calls now mints the admin scope it needs (2026-06-09 modular-UI
|
|
23
|
+
* architecture, P3).
|
|
24
|
+
*
|
|
25
|
+
* Scope choice — `channel:read channel:send channel:admin`, deliberately NOT
|
|
26
|
+
* `channel:write`:
|
|
27
|
+
* - `channel:read` — receive replies over SSE.
|
|
28
|
+
* - `channel:send` — post a message into the channel.
|
|
29
|
+
* - `channel:admin` — list + edit channel config (the config UI).
|
|
30
|
+
* - `channel:write` is the *session-reply* scope (a connected Claude Code
|
|
31
|
+
* session replying on a channel). A UI token must not be able to
|
|
32
|
+
* impersonate a session, so we never mint `channel:write` here.
|
|
33
|
+
*
|
|
34
|
+
* Audience: `channel` (the bare service prefix). Channel validates the JWT's
|
|
35
|
+
* `aud` claim against the literal string `"channel"` (parachute-channel
|
|
36
|
+
* `src/hub-jwt.ts` `CHANNEL_AUDIENCE`), the same shape `inferAudience` in
|
|
37
|
+
* oauth-handlers.ts stamps for the public OAuth flow — so hub-minted and
|
|
38
|
+
* OAuth-minted channel tokens are indistinguishable to channel. Unlike the
|
|
39
|
+
* per-vault admin token (`vault.<name>`), channel has a single bare audience.
|
|
40
|
+
*
|
|
41
|
+
* Multi-user Phase 1 gate: the session must belong to the first admin (the
|
|
42
|
+
* single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`),
|
|
43
|
+
* mirroring host-admin-token and vault-admin-token. Friends pinned to a vault
|
|
44
|
+
* use the OAuth flow for their assigned scopes; they don't get a channel
|
|
45
|
+
* Bearer via this endpoint.
|
|
46
|
+
*
|
|
47
|
+
* Tokens minted here are short-lived (10 min — matches host/vault admin
|
|
48
|
+
* tokens); the UI re-fetches on near-expiry.
|
|
49
|
+
*/
|
|
50
|
+
import type { Database } from "bun:sqlite";
|
|
51
|
+
import { signAccessToken } from "./jwt-sign.ts";
|
|
52
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
53
|
+
import { isFirstAdmin } from "./users.ts";
|
|
54
|
+
|
|
55
|
+
/** Short TTL — matches host/vault admin-token. UI re-fetches on near-expiry. */
|
|
56
|
+
export const CHANNEL_TOKEN_TTL_SECONDS = 10 * 60;
|
|
57
|
+
const CHANNEL_AUDIENCE = "channel";
|
|
58
|
+
const CHANNEL_CLIENT_ID = "parachute-hub-spa";
|
|
59
|
+
/**
|
|
60
|
+
* `channel:read` (SSE replies) + `channel:send` (post a message) +
|
|
61
|
+
* `channel:admin` (list + edit channel config — the config UI). Deliberately
|
|
62
|
+
* NOT `channel:write` — that's the session-reply scope, and a UI token must
|
|
63
|
+
* not be able to impersonate a connected session. The chat UI ignores the
|
|
64
|
+
* extra `channel:admin`; the config UI needs it (2026-06-09 modular-UI
|
|
65
|
+
* architecture, P3 — the hub endpoint the channel config UI already calls
|
|
66
|
+
* mints the admin scope so the channel repo doesn't have to change).
|
|
67
|
+
*/
|
|
68
|
+
export const CHANNEL_TOKEN_SCOPES = ["channel:read", "channel:send", "channel:admin"] as const;
|
|
69
|
+
|
|
70
|
+
export interface MintChannelTokenDeps {
|
|
71
|
+
db: Database;
|
|
72
|
+
/** Hub origin — written into JWT `iss`. */
|
|
73
|
+
issuer: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function handleChannelToken(
|
|
77
|
+
req: Request,
|
|
78
|
+
deps: MintChannelTokenDeps,
|
|
79
|
+
): Promise<Response> {
|
|
80
|
+
if (req.method !== "GET") {
|
|
81
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
82
|
+
}
|
|
83
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
84
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
85
|
+
if (!session) {
|
|
86
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
87
|
+
}
|
|
88
|
+
// First-admin gate (mirrors host/vault-admin-token). A friend account
|
|
89
|
+
// (non-first-admin user created via `/api/users`) holds a valid session but
|
|
90
|
+
// must not mint a channel Bearer. Without this check, any signed-in friend
|
|
91
|
+
// hitting `GET /admin/channel-token` would walk away with a token carrying
|
|
92
|
+
// `channel:read channel:send`.
|
|
93
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
94
|
+
return jsonError(
|
|
95
|
+
403,
|
|
96
|
+
"not_admin",
|
|
97
|
+
"channel token mint is restricted to the hub admin — your account home is at /account/",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const minted = await signAccessToken(deps.db, {
|
|
101
|
+
sub: session.userId,
|
|
102
|
+
scopes: [...CHANNEL_TOKEN_SCOPES],
|
|
103
|
+
audience: CHANNEL_AUDIENCE,
|
|
104
|
+
clientId: CHANNEL_CLIENT_ID,
|
|
105
|
+
issuer: deps.issuer,
|
|
106
|
+
ttlSeconds: CHANNEL_TOKEN_TTL_SECONDS,
|
|
107
|
+
// Channel tokens carry no per-user vault pin — the UI Bearer talks to a
|
|
108
|
+
// channel-scoped endpoint, not to a single vault. Empty `vault_scope` is
|
|
109
|
+
// the "no per-user restriction" sentinel matching host-admin tokens.
|
|
110
|
+
vaultScope: [],
|
|
111
|
+
});
|
|
112
|
+
return new Response(
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
token: minted.token,
|
|
115
|
+
expires_at: minted.expiresAt,
|
|
116
|
+
scopes: CHANNEL_TOKEN_SCOPES,
|
|
117
|
+
}),
|
|
118
|
+
{
|
|
119
|
+
status: 200,
|
|
120
|
+
headers: {
|
|
121
|
+
"content-type": "application/json",
|
|
122
|
+
// No browser cache — token rotates per-fetch, and a stale 200 from a
|
|
123
|
+
// back/forward navigation could hand the UI a long-expired JWT.
|
|
124
|
+
"cache-control": "no-store",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
131
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
132
|
+
status,
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
});
|
|
135
|
+
}
|