@openparachute/hub 0.7.0 → 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 +276 -6
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- 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 +266 -0
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-connections.ts +916 -14
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-vaults.ts +9 -0
- package/src/api-invites.ts +92 -12
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/connections-store.ts +32 -2
- 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 +501 -12
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +7 -1
- package/src/module-manifest.ts +107 -0
- package/src/origin-check.ts +7 -4
- package/src/services-manifest.ts +97 -0
- 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/index.html +1 -1
- package/web/ui/dist/assets/index-C-XzMVqN.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", () => {
|
|
@@ -783,9 +830,10 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
|
|
|
783
830
|
expect(userCount(harness.db) - before).toBe(1);
|
|
784
831
|
});
|
|
785
832
|
|
|
786
|
-
test("shared-vault invite
|
|
787
|
-
//
|
|
788
|
-
//
|
|
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.
|
|
789
837
|
await createUser(harness.db, "owner", "owner-strong-password-1", {
|
|
790
838
|
assignedVaults: ["legacy"],
|
|
791
839
|
role: "write",
|
|
@@ -798,6 +846,46 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
|
|
|
798
846
|
createdBy: admin.id,
|
|
799
847
|
vaultName: "legacy",
|
|
800
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",
|
|
801
889
|
});
|
|
802
890
|
const before = userCount(harness.db);
|
|
803
891
|
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
@@ -819,13 +907,195 @@ describe("POST /account/setup/<token> — cross-tenant: existing-vault rejection
|
|
|
819
907
|
expect(res.status).toBe(400);
|
|
820
908
|
expect(getUserByUsernameCI(harness.db, "wouldbe")).toBeNull();
|
|
821
909
|
expect(userCount(harness.db) - before).toBe(0);
|
|
822
|
-
// No vault shell-out — rejected before provisioning.
|
|
823
910
|
expect(stub.calls.length).toBe(0);
|
|
824
|
-
// Invite NOT consumed.
|
|
825
911
|
expect(findInviteByRawToken(harness.db, rawToken)?.usedAt).toBeNull();
|
|
826
912
|
});
|
|
827
913
|
});
|
|
828
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
|
+
|
|
829
1099
|
describe("POST /account/setup/<token> — vault name defaults to username (FIX-2)", () => {
|
|
830
1100
|
test("blank vault_name → vault created NAMED AFTER the username", async () => {
|
|
831
1101
|
const admin = await createUser(harness.db, "operator", "operator-password-1");
|