@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
package/package.json
CHANGED
|
@@ -21,11 +21,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
21
21
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
+
import { hasScope } from "../../packages/scope-guard/src/scope.ts";
|
|
24
25
|
import { handleAccountSetupGet, handleAccountSetupPost } from "../account-setup.ts";
|
|
26
|
+
import { handleAccountVaultTokenPost } from "../account-vault-token.ts";
|
|
25
27
|
import type { RunResult } from "../admin-vaults.ts";
|
|
26
28
|
import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
27
29
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
28
30
|
import { consumeInvite, findInviteByRawToken, issueInvite, revokeInvite } from "../invites.ts";
|
|
31
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
29
32
|
import { __resetForTests } from "../rate-limit.ts";
|
|
30
33
|
import { findActiveSession } from "../sessions.ts";
|
|
31
34
|
import { createUser, getUserByUsernameCI, userCount, vaultVerbsForUserVault } from "../users.ts";
|
|
@@ -296,11 +299,16 @@ describe("POST /account/setup/<token> — security invariants", () => {
|
|
|
296
299
|
expect(firstId).not.toBe(id);
|
|
297
300
|
});
|
|
298
301
|
|
|
299
|
-
test("a 'read' invite lands a read-only assignment", async () => {
|
|
302
|
+
test("a 'read' invite (shared-vault shape) lands a read-only assignment", async () => {
|
|
303
|
+
// read+provision is no longer a redeemable shape (a fresh vault's sole
|
|
304
|
+
// user must hold write — see the hand-edited-row guard test below), so
|
|
305
|
+
// the read role rides the shared-vault shape: assign an EXISTING vault.
|
|
300
306
|
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
307
|
+
seedExistingVault("shared");
|
|
301
308
|
const { rawToken } = issueInvite(harness.db, {
|
|
302
309
|
createdBy: admin.id,
|
|
303
310
|
vaultName: "shared",
|
|
311
|
+
provisionVault: false,
|
|
304
312
|
role: "read",
|
|
305
313
|
});
|
|
306
314
|
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
@@ -322,6 +330,45 @@ describe("POST /account/setup/<token> — security invariants", () => {
|
|
|
322
330
|
const user = getUserByUsernameCI(harness.db, "guest");
|
|
323
331
|
expect(vaultVerbsForUserVault(harness.db, user?.id ?? "", "shared")).toEqual(["read"]);
|
|
324
332
|
});
|
|
333
|
+
|
|
334
|
+
test("hand-edited provision_vault=1 + role='read' row → refused at redeem (no un-writable owner), invite unconsumed", async () => {
|
|
335
|
+
// The API refuses to MINT this shape (400 invalid_request); this row can
|
|
336
|
+
// only exist via a hand edit. The redeem-side guard must refuse it too —
|
|
337
|
+
// honoring it would provision a vault whose ONLY user can never write.
|
|
338
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
339
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
340
|
+
createdBy: admin.id,
|
|
341
|
+
vaultName: "deadend",
|
|
342
|
+
provisionVault: true,
|
|
343
|
+
role: "read",
|
|
344
|
+
});
|
|
345
|
+
const before = userCount(harness.db);
|
|
346
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
347
|
+
const stub = makeStubRunCommand();
|
|
348
|
+
const res = await handleAccountSetupPost(
|
|
349
|
+
postReq(
|
|
350
|
+
rawToken,
|
|
351
|
+
{
|
|
352
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
353
|
+
username: "guest",
|
|
354
|
+
password: "guest-strong-password-1",
|
|
355
|
+
password_confirm: "guest-strong-password-1",
|
|
356
|
+
},
|
|
357
|
+
cookieFragment,
|
|
358
|
+
),
|
|
359
|
+
rawToken,
|
|
360
|
+
deps(stub.run),
|
|
361
|
+
);
|
|
362
|
+
expect(res.status).toBe(400);
|
|
363
|
+
const html = await res.text();
|
|
364
|
+
expect(html).toContain("must have write access");
|
|
365
|
+
// No account, no vault shell-out, invite re-usable for the operator to
|
|
366
|
+
// revoke + re-mint correctly.
|
|
367
|
+
expect(getUserByUsernameCI(harness.db, "guest")).toBeNull();
|
|
368
|
+
expect(userCount(harness.db) - before).toBe(0);
|
|
369
|
+
expect(stub.calls.length).toBe(0);
|
|
370
|
+
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
|
|
371
|
+
});
|
|
325
372
|
});
|
|
326
373
|
|
|
327
374
|
describe("POST /account/setup/<token> — rejection paths", () => {
|
|
@@ -527,6 +574,40 @@ describe("POST /account/setup/<token> — vault-name validation (N1)", () => {
|
|
|
527
574
|
// No account created.
|
|
528
575
|
expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
|
|
529
576
|
});
|
|
577
|
+
|
|
578
|
+
test("an invitee-chosen RESERVED vault name (list/new/assets/admin) → 400, never provisioned (B2h)", async () => {
|
|
579
|
+
// Pre-consolidation, the invite path's validator reserved only "list" —
|
|
580
|
+
// a non-admin invite redeemer could squat "admin" and capture the
|
|
581
|
+
// daemon-level /vault/admin mount. One consolidated set closes that.
|
|
582
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
583
|
+
for (const name of ["list", "new", "assets", "admin"]) {
|
|
584
|
+
// vault_name null → the redeemer names their own vault.
|
|
585
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
|
|
586
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
587
|
+
const stub = makeStubRunCommand();
|
|
588
|
+
const res = await handleAccountSetupPost(
|
|
589
|
+
postReq(
|
|
590
|
+
rawToken,
|
|
591
|
+
{
|
|
592
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
593
|
+
username: "sam",
|
|
594
|
+
password: "sam-strong-password-12",
|
|
595
|
+
password_confirm: "sam-strong-password-12",
|
|
596
|
+
vault_name: name,
|
|
597
|
+
},
|
|
598
|
+
cookieFragment,
|
|
599
|
+
),
|
|
600
|
+
rawToken,
|
|
601
|
+
deps(stub.run),
|
|
602
|
+
);
|
|
603
|
+
expect(res.status).toBe(400);
|
|
604
|
+
const html = await res.text();
|
|
605
|
+
expect(html).toContain("reserved");
|
|
606
|
+
// The vault CLI is never reached; no account created.
|
|
607
|
+
expect(stub.calls.length).toBe(0);
|
|
608
|
+
expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
530
611
|
});
|
|
531
612
|
|
|
532
613
|
describe("POST /account/setup/<token> — concurrent redeem (N2)", () => {
|
|
@@ -749,9 +830,10 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
|
|
|
749
830
|
expect(userCount(harness.db) - before).toBe(1);
|
|
750
831
|
});
|
|
751
832
|
|
|
752
|
-
test("shared-vault invite
|
|
753
|
-
//
|
|
754
|
-
//
|
|
833
|
+
test("shared-vault invite (provision_vault=false + pinned EXISTING name) → assigns at the invite's role, NO provisioning", async () => {
|
|
834
|
+
// The supported shared-vault shape: host:admin minted this invite, the
|
|
835
|
+
// same authority that can assign any user to any vault via POST
|
|
836
|
+
// /api/users — the invite just packages that assignment as a link.
|
|
755
837
|
await createUser(harness.db, "owner", "owner-strong-password-1", {
|
|
756
838
|
assignedVaults: ["legacy"],
|
|
757
839
|
role: "write",
|
|
@@ -764,6 +846,46 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
|
|
|
764
846
|
createdBy: admin.id,
|
|
765
847
|
vaultName: "legacy",
|
|
766
848
|
provisionVault: false,
|
|
849
|
+
role: "write",
|
|
850
|
+
});
|
|
851
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
852
|
+
const stub = makeStubRunCommand();
|
|
853
|
+
const res = await handleAccountSetupPost(
|
|
854
|
+
postReq(
|
|
855
|
+
rawToken,
|
|
856
|
+
{
|
|
857
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
858
|
+
username: "guest",
|
|
859
|
+
password: "guest-strong-password-11",
|
|
860
|
+
password_confirm: "guest-strong-password-11",
|
|
861
|
+
},
|
|
862
|
+
cookieFragment,
|
|
863
|
+
),
|
|
864
|
+
rawToken,
|
|
865
|
+
deps(stub.run),
|
|
866
|
+
);
|
|
867
|
+
expect(res.status).toBe(302);
|
|
868
|
+
// NO provisioning shell-out — the vault already exists.
|
|
869
|
+
expect(stub.calls.length).toBe(0);
|
|
870
|
+
const user = getUserByUsernameCI(harness.db, "guest");
|
|
871
|
+
expect(user?.assignedVaults).toEqual(["legacy"]);
|
|
872
|
+
expect(vaultVerbsForUserVault(harness.db, user?.id ?? "", "legacy")).toEqual([
|
|
873
|
+
"read",
|
|
874
|
+
"write",
|
|
875
|
+
"admin",
|
|
876
|
+
]);
|
|
877
|
+
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).not.toBeNull();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("shared-vault invite whose vault VANISHED from services.json → 400, invite unconsumed", async () => {
|
|
881
|
+
// The vault-delete cascade revokes pending invites, so this is the
|
|
882
|
+
// defense-in-depth path (manual manifest edits / restored DB).
|
|
883
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
884
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
885
|
+
createdBy: admin.id,
|
|
886
|
+
vaultName: "ghost",
|
|
887
|
+
provisionVault: false,
|
|
888
|
+
role: "read",
|
|
767
889
|
});
|
|
768
890
|
const before = userCount(harness.db);
|
|
769
891
|
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
@@ -785,13 +907,195 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
|
|
|
785
907
|
expect(res.status).toBe(400);
|
|
786
908
|
expect(getUserByUsernameCI(harness.db, "wouldbe")).toBeNull();
|
|
787
909
|
expect(userCount(harness.db) - before).toBe(0);
|
|
788
|
-
// No vault shell-out — rejected before provisioning.
|
|
789
910
|
expect(stub.calls.length).toBe(0);
|
|
790
|
-
// Invite NOT consumed.
|
|
791
911
|
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
|
|
792
912
|
});
|
|
793
913
|
});
|
|
794
914
|
|
|
915
|
+
describe("POST /account/setup/<token> — the Adam/Jonathan read-only shared-vault e2e", () => {
|
|
916
|
+
test("redeem read-role shared invite → read mints, write/admin/cross-vault REFUSED, vault-side scope check refuses writes", async () => {
|
|
917
|
+
// End-to-end pin of the read-role enforcement chain:
|
|
918
|
+
// invite(role=read, existing vault) → redeem → user_vaults row role=read
|
|
919
|
+
// → /account/vault-token mint caps to vaultVerbsForRole ('read' only)
|
|
920
|
+
// → the minted JWT carries vault:<name>:read + the vault_scope pin
|
|
921
|
+
// → scope-guard (what the vault runs per request) refuses write/admin.
|
|
922
|
+
await createUser(harness.db, "adam", "adams-strong-password-1", {
|
|
923
|
+
assignedVaults: ["jonathan-vault", "adams-vault"],
|
|
924
|
+
role: "write",
|
|
925
|
+
});
|
|
926
|
+
seedExistingVault("jonathan-vault");
|
|
927
|
+
seedExistingVault("adams-vault");
|
|
928
|
+
const admin = getUserByUsernameCI(harness.db, "adam");
|
|
929
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
930
|
+
createdBy: admin?.id ?? "",
|
|
931
|
+
vaultName: "jonathan-vault",
|
|
932
|
+
username: "jonathan",
|
|
933
|
+
provisionVault: false,
|
|
934
|
+
role: "read",
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// Redeem (the form's username field is absent — pre-named).
|
|
938
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
939
|
+
const stub = makeStubRunCommand();
|
|
940
|
+
const res = await handleAccountSetupPost(
|
|
941
|
+
postReq(
|
|
942
|
+
rawToken,
|
|
943
|
+
{
|
|
944
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
945
|
+
password: "jonathans-strong-pass-1",
|
|
946
|
+
password_confirm: "jonathans-strong-pass-1",
|
|
947
|
+
},
|
|
948
|
+
cookieFragment,
|
|
949
|
+
),
|
|
950
|
+
rawToken,
|
|
951
|
+
deps(stub.run),
|
|
952
|
+
);
|
|
953
|
+
expect(res.status).toBe(302);
|
|
954
|
+
const jonathan = getUserByUsernameCI(harness.db, "jonathan");
|
|
955
|
+
expect(jonathan).not.toBeNull();
|
|
956
|
+
// (1) The user_vaults row is read.
|
|
957
|
+
const row = harness.db
|
|
958
|
+
.query<{ role: string }, [string]>(
|
|
959
|
+
"SELECT role FROM user_vaults WHERE user_id = ? AND vault_name = 'jonathan-vault'",
|
|
960
|
+
)
|
|
961
|
+
.get(jonathan?.id ?? "");
|
|
962
|
+
expect(row?.role).toBe("read");
|
|
963
|
+
expect(vaultVerbsForUserVault(harness.db, jonathan?.id ?? "", "jonathan-vault")).toEqual([
|
|
964
|
+
"read",
|
|
965
|
+
]);
|
|
966
|
+
|
|
967
|
+
// (2) Enforcement at the mint surface — drive the REAL /account mint
|
|
968
|
+
// handler with the session the redeem just created.
|
|
969
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
970
|
+
const sessionFragment = setCookie.split(";")[0] ?? "";
|
|
971
|
+
const { token: mintCsrf, cookieFragment: mintCsrfCookie } = csrfPair();
|
|
972
|
+
const cookie = `${sessionFragment}; ${mintCsrfCookie}`;
|
|
973
|
+
const mint = (vault: string, verb: string) =>
|
|
974
|
+
handleAccountVaultTokenPost(
|
|
975
|
+
new Request(`${ISSUER}/account/vault-token/${vault}`, {
|
|
976
|
+
method: "POST",
|
|
977
|
+
headers: { "content-type": "application/x-www-form-urlencoded", cookie },
|
|
978
|
+
body: new URLSearchParams({ [CSRF_FIELD_NAME]: mintCsrf, verb }).toString(),
|
|
979
|
+
}),
|
|
980
|
+
vault,
|
|
981
|
+
{ db: harness.db, hubOrigin: ISSUER },
|
|
982
|
+
);
|
|
983
|
+
expect((await mint("jonathan-vault", "write")).status).toBe(403);
|
|
984
|
+
expect((await mint("jonathan-vault", "admin")).status).toBe(403);
|
|
985
|
+
// (3) Cannot touch the OTHER vault at all.
|
|
986
|
+
expect((await mint("adams-vault", "read")).status).toBe(403);
|
|
987
|
+
|
|
988
|
+
const readRes = await mint("jonathan-vault", "read");
|
|
989
|
+
expect(readRes.status).toBe(200);
|
|
990
|
+
const html = await readRes.text();
|
|
991
|
+
const m = html.match(/data-testid="minted-token-value">([^<]+)</);
|
|
992
|
+
expect(m).not.toBeNull();
|
|
993
|
+
const validated = await validateAccessToken(harness.db, m?.[1] ?? "", ISSUER);
|
|
994
|
+
const scopes = ((validated.payload as { scope?: string }).scope ?? "").split(/\s+/);
|
|
995
|
+
expect(scopes).toEqual(["vault:jonathan-vault:read"]);
|
|
996
|
+
expect((validated.payload as { vault_scope?: string[] }).vault_scope).toEqual([
|
|
997
|
+
"jonathan-vault",
|
|
998
|
+
]);
|
|
999
|
+
|
|
1000
|
+
// (4) The resource-server side: scope-guard (what the vault runs) refuses
|
|
1001
|
+
// writes for this token and refuses the other vault entirely. Hub never
|
|
1002
|
+
// imports scope-guard at runtime (issuer vs validator boundary); the test
|
|
1003
|
+
// crosses it deliberately to pin the cross-system contract.
|
|
1004
|
+
expect(hasScope(scopes, "vault:jonathan-vault:write")).toBe(false);
|
|
1005
|
+
expect(hasScope(scopes, "vault:jonathan-vault:admin")).toBe(false);
|
|
1006
|
+
expect(hasScope(scopes, "vault:jonathan-vault:read")).toBe(true);
|
|
1007
|
+
expect(hasScope(scopes, "vault:adams-vault:read")).toBe(false);
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
describe("POST /account/setup/<token> — pre-named username (ENFORCED)", () => {
|
|
1012
|
+
test("the invite's username wins — a different submitted username is ignored", async () => {
|
|
1013
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
1014
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
1015
|
+
createdBy: admin.id,
|
|
1016
|
+
username: "jonathan",
|
|
1017
|
+
vaultName: "maya",
|
|
1018
|
+
});
|
|
1019
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
1020
|
+
const stub = makeStubRunCommand();
|
|
1021
|
+
const res = await handleAccountSetupPost(
|
|
1022
|
+
postReq(
|
|
1023
|
+
rawToken,
|
|
1024
|
+
{
|
|
1025
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
1026
|
+
// Submitted name must NOT be honored — the invite is a named deliverable.
|
|
1027
|
+
username: "imposter",
|
|
1028
|
+
password: "jonathans-strong-pass-1",
|
|
1029
|
+
password_confirm: "jonathans-strong-pass-1",
|
|
1030
|
+
},
|
|
1031
|
+
cookieFragment,
|
|
1032
|
+
),
|
|
1033
|
+
rawToken,
|
|
1034
|
+
deps(stub.run),
|
|
1035
|
+
);
|
|
1036
|
+
expect(res.status).toBe(302);
|
|
1037
|
+
expect(getUserByUsernameCI(harness.db, "imposter")).toBeNull();
|
|
1038
|
+
expect(getUserByUsernameCI(harness.db, "jonathan")).not.toBeNull();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("pre-named username TAKEN at redeem time → 409 'ask your operator', invite stays re-usable", async () => {
|
|
1042
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
1043
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
1044
|
+
createdBy: admin.id,
|
|
1045
|
+
username: "jonathan",
|
|
1046
|
+
vaultName: "maya",
|
|
1047
|
+
});
|
|
1048
|
+
// Someone takes the name between mint and redeem.
|
|
1049
|
+
await createUser(harness.db, "jonathan", "squatter-strong-pass-1", { allowMulti: true });
|
|
1050
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
1051
|
+
const stub = makeStubRunCommand();
|
|
1052
|
+
const res = await handleAccountSetupPost(
|
|
1053
|
+
postReq(
|
|
1054
|
+
rawToken,
|
|
1055
|
+
{
|
|
1056
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
1057
|
+
password: "jonathans-strong-pass-1",
|
|
1058
|
+
password_confirm: "jonathans-strong-pass-1",
|
|
1059
|
+
},
|
|
1060
|
+
cookieFragment,
|
|
1061
|
+
),
|
|
1062
|
+
rawToken,
|
|
1063
|
+
deps(stub.run),
|
|
1064
|
+
);
|
|
1065
|
+
expect(res.status).toBe(409);
|
|
1066
|
+
const html = await res.text();
|
|
1067
|
+
expect(html).toContain("Ask your hub operator");
|
|
1068
|
+
// No vault provisioned, invite NOT consumed — operator can revoke + re-mint.
|
|
1069
|
+
expect(stub.calls.length).toBe(0);
|
|
1070
|
+
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
test("GET renders the pre-named username read-only (no editable username field)", async () => {
|
|
1074
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
1075
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
1076
|
+
createdBy: admin.id,
|
|
1077
|
+
username: "jonathan",
|
|
1078
|
+
vaultName: "shared",
|
|
1079
|
+
provisionVault: false,
|
|
1080
|
+
role: "read",
|
|
1081
|
+
});
|
|
1082
|
+
seedExistingVault("shared");
|
|
1083
|
+
const res = handleAccountSetupGet(
|
|
1084
|
+
new Request(`${ISSUER}/account/setup/${rawToken}`),
|
|
1085
|
+
rawToken,
|
|
1086
|
+
deps(),
|
|
1087
|
+
);
|
|
1088
|
+
expect(res.status).toBe(200);
|
|
1089
|
+
const html = await res.text();
|
|
1090
|
+
expect(html).toContain("jonathan");
|
|
1091
|
+
expect(html).toContain("Your hub operator chose this username for you.");
|
|
1092
|
+
// No editable username input on a pre-named form.
|
|
1093
|
+
expect(html).not.toContain('name="username"');
|
|
1094
|
+
// Shared-vault invites surface the role.
|
|
1095
|
+
expect(html).toContain("read-only");
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
|
|
795
1099
|
describe("POST /account/setup/<token> — vault name defaults to username (FIX-2)", () => {
|
|
796
1100
|
test("blank vault_name → vault created NAMED AFTER the username", async () => {
|
|
797
1101
|
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
@@ -128,7 +128,8 @@ describe("handleAccountVaultAdminTokenPost — happy path (assigned vault)", ()
|
|
|
128
128
|
expect(res.status).toBe(303);
|
|
129
129
|
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
130
130
|
const location = res.headers.get("location") ?? "";
|
|
131
|
-
// Default managementUrl is
|
|
131
|
+
// Default managementUrl is the relative "admin/" (B4 per-instance form)
|
|
132
|
+
// → lands on the vault admin SPA home under the vault's mount.
|
|
132
133
|
expect(location.startsWith(`${ISSUER}/vault/work/admin/#token=`)).toBe(true);
|
|
133
134
|
|
|
134
135
|
const token = tokenFromLocation(location);
|
|
@@ -151,18 +152,49 @@ describe("handleAccountVaultAdminTokenPost — happy path (assigned vault)", ()
|
|
|
151
152
|
expect(rows?.n).toBe(1);
|
|
152
153
|
});
|
|
153
154
|
|
|
154
|
-
test("honors a vault-declared managementUrl (
|
|
155
|
+
test("honors a vault-declared RELATIVE managementUrl (B4 per-instance form)", async () => {
|
|
155
156
|
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
156
157
|
const res = await handleAccountVaultAdminTokenPost(
|
|
157
158
|
mintReq("work", { cookie, csrfToken }),
|
|
158
159
|
"work",
|
|
159
|
-
deps({ managementUrl: "
|
|
160
|
+
deps({ managementUrl: "manage/" }),
|
|
160
161
|
);
|
|
161
162
|
expect(res.status).toBe(303);
|
|
162
163
|
const location = res.headers.get("location") ?? "";
|
|
163
164
|
expect(location.startsWith(`${ISSUER}/vault/work/manage/#token=`)).toBe(true);
|
|
164
165
|
});
|
|
165
166
|
|
|
167
|
+
test("a LEADING-SLASH managementUrl resolves origin-absolute (B4 inverted pin)", async () => {
|
|
168
|
+
// Pre-B4 "/manage/" joined under the vault mount (/vault/work/manage/).
|
|
169
|
+
// Under the unified semantics a leading-"/" is origin-absolute.
|
|
170
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
171
|
+
const res = await handleAccountVaultAdminTokenPost(
|
|
172
|
+
mintReq("work", { cookie, csrfToken }),
|
|
173
|
+
"work",
|
|
174
|
+
deps({ managementUrl: "/manage/" }),
|
|
175
|
+
);
|
|
176
|
+
expect(res.status).toBe(303);
|
|
177
|
+
const location = res.headers.get("location") ?? "";
|
|
178
|
+
expect(location.startsWith(`${ISSUER}/manage/#token=`)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('COMPAT SHIM: the literal legacy "/admin/" managementUrl still joins under the vault (one release)', async () => {
|
|
182
|
+
// Deployed vaults declare managementUrl "/admin/" — the OLD per-instance
|
|
183
|
+
// form. Origin-absolute resolution would deep-link the daemon-level
|
|
184
|
+
// /vault/admin mount instead of the instance SPA, so the literal
|
|
185
|
+
// "/admin"/"/admin/" keeps the old vault-join for one release with a
|
|
186
|
+
// deprecation log.
|
|
187
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
188
|
+
const res = await handleAccountVaultAdminTokenPost(
|
|
189
|
+
mintReq("work", { cookie, csrfToken }),
|
|
190
|
+
"work",
|
|
191
|
+
deps({ managementUrl: "/admin/" }),
|
|
192
|
+
);
|
|
193
|
+
expect(res.status).toBe(303);
|
|
194
|
+
const location = res.headers.get("location") ?? "";
|
|
195
|
+
expect(location.startsWith(`${ISSUER}/vault/work/admin/#token=`)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
166
198
|
test("a friend assigned to multiple vaults can deep-link each, never cross-vault", async () => {
|
|
167
199
|
const { cookie, csrfToken } = await seedFriend(["work", "home"]);
|
|
168
200
|
for (const v of ["work", "home"]) {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the channel UI session→bearer mint endpoint. Mirrors
|
|
3
|
+
* `admin-host-admin-token.test.ts` shape (channel has a single bare audience,
|
|
4
|
+
* no per-vault name). Covers:
|
|
5
|
+
* - 401 when no admin session cookie is present.
|
|
6
|
+
* - 401 when the cookie names a deleted session.
|
|
7
|
+
* - 405 on POST.
|
|
8
|
+
* - 200 + JWT carrying `aud: "channel"` and `channel:read channel:send channel:admin`.
|
|
9
|
+
* - First-admin gate: 403 for a signed-in non-first-admin (friend); the
|
|
10
|
+
* admin's happy path still mints when a friend exists alongside.
|
|
11
|
+
*/
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { CHANNEL_TOKEN_TTL_SECONDS, handleChannelToken } from "../admin-channel-token.ts";
|
|
18
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
19
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
20
|
+
import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
|
|
21
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
22
|
+
import { createUser } from "../users.ts";
|
|
23
|
+
|
|
24
|
+
const ISSUER = "https://hub.test";
|
|
25
|
+
|
|
26
|
+
interface Harness {
|
|
27
|
+
db: Database;
|
|
28
|
+
cleanup: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeHarness(): Harness {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-channel-token-"));
|
|
33
|
+
const db = openHubDb(hubDbPath(dir));
|
|
34
|
+
return {
|
|
35
|
+
db,
|
|
36
|
+
cleanup: () => {
|
|
37
|
+
db.close();
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let harness: Harness;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
harness = makeHarness();
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
harness.cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function withSession(): Promise<{ cookie: string; userId: string }> {
|
|
52
|
+
const user = await createUser(harness.db, "operator", "hunter2");
|
|
53
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
54
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
55
|
+
return { cookie, userId: user.id };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Seed an admin (first-created user) + a second non-admin "friend" account,
|
|
60
|
+
* return cookies + ids for both. Used by the first-admin-gate tests.
|
|
61
|
+
*/
|
|
62
|
+
async function withAdminAndFriend(): Promise<{
|
|
63
|
+
adminCookie: string;
|
|
64
|
+
adminId: string;
|
|
65
|
+
friendCookie: string;
|
|
66
|
+
friendId: string;
|
|
67
|
+
}> {
|
|
68
|
+
const admin = await createUser(harness.db, "admin", "admin-passphrase");
|
|
69
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
70
|
+
allowMulti: true,
|
|
71
|
+
});
|
|
72
|
+
const adminSession = createSession(harness.db, { userId: admin.id });
|
|
73
|
+
const friendSession = createSession(harness.db, { userId: friend.id });
|
|
74
|
+
return {
|
|
75
|
+
adminCookie: buildSessionCookie(adminSession.id, Math.floor(SESSION_TTL_MS / 1000)),
|
|
76
|
+
adminId: admin.id,
|
|
77
|
+
friendCookie: buildSessionCookie(friendSession.id, Math.floor(SESSION_TTL_MS / 1000)),
|
|
78
|
+
friendId: friend.id,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("handleChannelToken", () => {
|
|
83
|
+
test("401 when no session cookie is present", async () => {
|
|
84
|
+
const req = new Request(`${ISSUER}/admin/channel-token`);
|
|
85
|
+
const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
|
|
86
|
+
expect(res.status).toBe(401);
|
|
87
|
+
const body = (await res.json()) as { error: string };
|
|
88
|
+
expect(body.error).toBe("unauthenticated");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("401 when the cookie names a deleted session", async () => {
|
|
92
|
+
const { cookie } = await withSession();
|
|
93
|
+
const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
|
|
94
|
+
deleteSession(harness.db, sid);
|
|
95
|
+
const req = new Request(`${ISSUER}/admin/channel-token`, { headers: { cookie } });
|
|
96
|
+
const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
|
|
97
|
+
expect(res.status).toBe(401);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("405 on POST", async () => {
|
|
101
|
+
const { cookie } = await withSession();
|
|
102
|
+
const req = new Request(`${ISSUER}/admin/channel-token`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { cookie },
|
|
105
|
+
});
|
|
106
|
+
const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
|
|
107
|
+
expect(res.status).toBe(405);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("200 mints a JWT carrying aud:channel + channel:read channel:send channel:admin", async () => {
|
|
111
|
+
const { cookie, userId } = await withSession();
|
|
112
|
+
rotateSigningKey(harness.db);
|
|
113
|
+
const req = new Request(`${ISSUER}/admin/channel-token`, { headers: { cookie } });
|
|
114
|
+
const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
117
|
+
|
|
118
|
+
const body = (await res.json()) as { token: string; expires_at: string; scopes: string[] };
|
|
119
|
+
// `channel:send` (post) + `channel:read` (SSE replies) + `channel:admin`
|
|
120
|
+
// (config UI list/edit — 2026-06-09 modular-UI architecture P3). Deliberately
|
|
121
|
+
// NOT `channel:write` — that's the session-reply scope a UI token must not hold.
|
|
122
|
+
expect(body.scopes).toEqual(["channel:read", "channel:send", "channel:admin"]);
|
|
123
|
+
expect(body.scopes).not.toContain("channel:write");
|
|
124
|
+
expect(body.token.length).toBeGreaterThan(20);
|
|
125
|
+
|
|
126
|
+
const expMs = new Date(body.expires_at).getTime();
|
|
127
|
+
const skew = expMs - Date.now();
|
|
128
|
+
expect(skew).toBeGreaterThan((CHANNEL_TOKEN_TTL_SECONDS - 30) * 1000);
|
|
129
|
+
expect(skew).toBeLessThan((CHANNEL_TOKEN_TTL_SECONDS + 30) * 1000);
|
|
130
|
+
|
|
131
|
+
const validated = await validateAccessToken(harness.db, body.token, ISSUER);
|
|
132
|
+
expect(validated.payload.sub).toBe(userId);
|
|
133
|
+
expect(validated.payload.iss).toBe(ISSUER);
|
|
134
|
+
// Bare service audience — channel validates `aud === "channel"`
|
|
135
|
+
// (parachute-channel src/hub-jwt.ts CHANNEL_AUDIENCE).
|
|
136
|
+
expect(validated.payload.aud).toBe("channel");
|
|
137
|
+
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
138
|
+
const scopes = scopeClaim.split(/\s+/);
|
|
139
|
+
expect(scopes).toContain("channel:read");
|
|
140
|
+
expect(scopes).toContain("channel:send");
|
|
141
|
+
expect(scopes).toContain("channel:admin");
|
|
142
|
+
expect(scopes).not.toContain("channel:write");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("403 not_admin when a signed-in non-first-admin (friend) hits the endpoint", async () => {
|
|
146
|
+
// Privesc closure (mirrors host/vault-admin-token). The friend's session
|
|
147
|
+
// is valid; the endpoint must refuse because session.userId isn't the
|
|
148
|
+
// first-admin row.
|
|
149
|
+
const { friendCookie } = await withAdminAndFriend();
|
|
150
|
+
rotateSigningKey(harness.db);
|
|
151
|
+
const req = new Request(`${ISSUER}/admin/channel-token`, {
|
|
152
|
+
headers: { cookie: friendCookie },
|
|
153
|
+
});
|
|
154
|
+
const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
|
|
155
|
+
expect(res.status).toBe(403);
|
|
156
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
157
|
+
expect(body.error).toBe("not_admin");
|
|
158
|
+
expect(body.error_description).toContain("/account/");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("first-admin path still succeeds when a friend exists alongside", async () => {
|
|
162
|
+
const { adminCookie, adminId } = await withAdminAndFriend();
|
|
163
|
+
rotateSigningKey(harness.db);
|
|
164
|
+
const req = new Request(`${ISSUER}/admin/channel-token`, {
|
|
165
|
+
headers: { cookie: adminCookie },
|
|
166
|
+
});
|
|
167
|
+
const res = await handleChannelToken(req, { db: harness.db, issuer: ISSUER });
|
|
168
|
+
expect(res.status).toBe(200);
|
|
169
|
+
const body = (await res.json()) as { token: string };
|
|
170
|
+
const validated = await validateAccessToken(harness.db, body.token, ISSUER);
|
|
171
|
+
expect(validated.payload.sub).toBe(adminId);
|
|
172
|
+
});
|
|
173
|
+
});
|