@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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 that slipped through (provision_vault=false + pinned name) → rejected at redeem", async () => {
787
- // Defense in depth: the admin API rejects creating this shape, but if one
788
- // exists in the DB the redeem must still refuse to assign the existing vault.
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");