@openparachute/hub 0.6.3 → 0.6.4-rc.10
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 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -28,8 +28,11 @@ import {
|
|
|
28
28
|
handleAccountHomeGet,
|
|
29
29
|
markPasswordChanged,
|
|
30
30
|
} from "../api-account.ts";
|
|
31
|
+
import { registerClient } from "../clients.ts";
|
|
31
32
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
33
|
+
import { recordGrant } from "../grants.ts";
|
|
32
34
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
35
|
+
import { recordTokenMint } from "../jwt-sign.ts";
|
|
33
36
|
import {
|
|
34
37
|
CHANGE_PASSWORD_MAX_ATTEMPTS,
|
|
35
38
|
CHANGE_PASSWORD_WINDOW_MS,
|
|
@@ -257,6 +260,63 @@ describe("POST /account/change-password", () => {
|
|
|
257
260
|
}
|
|
258
261
|
});
|
|
259
262
|
|
|
263
|
+
// Item F / hub#469 — a successful self-service password change revokes the
|
|
264
|
+
// user's still-active tokens (so a token minted under the admin's temp
|
|
265
|
+
// password dies with the rotation). Mirrors `resetUserPassword`'s admin-reset
|
|
266
|
+
// revoke. Tokens belonging to OTHER users are untouched.
|
|
267
|
+
test("self-change revokes the user's active tokens, leaves other users' tokens (item F)", async () => {
|
|
268
|
+
const { userId, cookie } = await sessionCookieFor(harness.db, "newbie", "old-default-pw", {
|
|
269
|
+
passwordChanged: false,
|
|
270
|
+
});
|
|
271
|
+
const other = await createUser(harness.db, "other", "other-strong-passphrase", {
|
|
272
|
+
passwordChanged: true,
|
|
273
|
+
allowMulti: true,
|
|
274
|
+
});
|
|
275
|
+
// Seed one active token for the changing user + one for another user.
|
|
276
|
+
recordTokenMint(harness.db, {
|
|
277
|
+
jti: "tok-self-1",
|
|
278
|
+
createdVia: "cli_mint",
|
|
279
|
+
subject: userId,
|
|
280
|
+
userId,
|
|
281
|
+
clientId: "parachute-account",
|
|
282
|
+
scopes: ["vault:work:read"],
|
|
283
|
+
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
284
|
+
});
|
|
285
|
+
recordTokenMint(harness.db, {
|
|
286
|
+
jti: "tok-other-1",
|
|
287
|
+
createdVia: "cli_mint",
|
|
288
|
+
subject: other.id,
|
|
289
|
+
userId: other.id,
|
|
290
|
+
clientId: "parachute-account",
|
|
291
|
+
scopes: ["vault:work:read"],
|
|
292
|
+
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const { body, headers } = formBody({
|
|
296
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
297
|
+
current_password: "old-default-pw",
|
|
298
|
+
new_password: "user-chosen-strong-passphrase",
|
|
299
|
+
new_password_confirm: "user-chosen-strong-passphrase",
|
|
300
|
+
next: "/account/",
|
|
301
|
+
});
|
|
302
|
+
const req = new Request("http://hub.test/account/change-password", {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { ...headers, cookie },
|
|
305
|
+
body,
|
|
306
|
+
});
|
|
307
|
+
const res = await handleAccountChangePasswordPost(req, { db: harness.db });
|
|
308
|
+
expect(res.status).toBe(302);
|
|
309
|
+
|
|
310
|
+
const selfTok = harness.db
|
|
311
|
+
.query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
|
|
312
|
+
.get("tok-self-1");
|
|
313
|
+
const otherTok = harness.db
|
|
314
|
+
.query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
|
|
315
|
+
.get("tok-other-1");
|
|
316
|
+
expect(selfTok?.revoked_at).not.toBeNull(); // changing user's token revoked
|
|
317
|
+
expect(otherTok?.revoked_at).toBeNull(); // other user's token untouched
|
|
318
|
+
});
|
|
319
|
+
|
|
260
320
|
test("non-admin user with no next defaults to /account/ (no admin-shell flash)", async () => {
|
|
261
321
|
// Without this rewrite, a friend's change-password POST would 302 to
|
|
262
322
|
// /admin/vaults, the SPA would load, the 403 from
|
|
@@ -743,7 +803,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
743
803
|
|
|
744
804
|
test("302 → /login when no session cookie is present", async () => {
|
|
745
805
|
const req = new Request(`${HUB_ORIGIN}/account/`);
|
|
746
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
806
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
747
807
|
expect(res.status).toBe(302);
|
|
748
808
|
const location = res.headers.get("location") ?? "";
|
|
749
809
|
// Round-trip /account/ as the `next` param so post-login lands back.
|
|
@@ -762,7 +822,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
762
822
|
const session = createSession(harness.db, { userId: friend.id });
|
|
763
823
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
764
824
|
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
765
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
825
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
766
826
|
expect(res.status).toBe(200);
|
|
767
827
|
expect(res.headers.get("content-type")).toContain("text/html");
|
|
768
828
|
const html = await res.text();
|
|
@@ -774,6 +834,65 @@ describe("handleAccountHomeGet", () => {
|
|
|
774
834
|
expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
|
|
775
835
|
});
|
|
776
836
|
|
|
837
|
+
test("onboarding NOT condensed when only a first-party browser grant exists (hub#583, GET /account/)", async () => {
|
|
838
|
+
// The exact field report: create account → open Notes (a first-party OAuth
|
|
839
|
+
// client that writes a vault-scoped grant) → later visit /account/ to wire
|
|
840
|
+
// up Claude. The checklist must NOT already be condensed.
|
|
841
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
842
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
843
|
+
allowMulti: true,
|
|
844
|
+
passwordChanged: true,
|
|
845
|
+
assignedVaults: ["alice"],
|
|
846
|
+
});
|
|
847
|
+
// Notes signs in via DCR (generated client_id, client_name "Notes") and
|
|
848
|
+
// records a vault-scoped grant.
|
|
849
|
+
const notes = registerClient(harness.db, {
|
|
850
|
+
redirectUris: ["https://hub.test/notes/cb"],
|
|
851
|
+
clientName: "Notes",
|
|
852
|
+
});
|
|
853
|
+
recordGrant(harness.db, friend.id, notes.client.clientId, ["vault:alice:read"]);
|
|
854
|
+
|
|
855
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
856
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
857
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
858
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
859
|
+
expect(res.status).toBe(200);
|
|
860
|
+
const html = await res.text();
|
|
861
|
+
// Full checklist (not condensed): the connect step is present, the
|
|
862
|
+
// "you're connected" done-line is NOT.
|
|
863
|
+
expect(html).toContain('data-connected="false"');
|
|
864
|
+
expect(html).toContain('data-testid="onboarding-step-2"');
|
|
865
|
+
expect(html).not.toContain('data-testid="onboarding-done-line"');
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("onboarding condensed when an external AI client grant exists (hub#583, GET /account/)", async () => {
|
|
869
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
870
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
871
|
+
allowMulti: true,
|
|
872
|
+
passwordChanged: true,
|
|
873
|
+
assignedVaults: ["alice"],
|
|
874
|
+
});
|
|
875
|
+
// Claude Code: a genuine external MCP client.
|
|
876
|
+
const claude = registerClient(harness.db, {
|
|
877
|
+
redirectUris: ["https://claude.ai/cb"],
|
|
878
|
+
clientName: "Claude",
|
|
879
|
+
});
|
|
880
|
+
recordGrant(harness.db, friend.id, claude.client.clientId, ["vault:alice:read"]);
|
|
881
|
+
|
|
882
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
883
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
884
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
885
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
886
|
+
expect(res.status).toBe(200);
|
|
887
|
+
const html = await res.text();
|
|
888
|
+
// Condensed done-state.
|
|
889
|
+
expect(html).toContain('data-connected="true"');
|
|
890
|
+
expect(html).toContain('data-testid="onboarding-done-line"');
|
|
891
|
+
expect(html).toContain("You're connected");
|
|
892
|
+
// And it still keeps the "Connect another AI" expander.
|
|
893
|
+
expect(html).toContain('data-testid="onboarding-connect-another"');
|
|
894
|
+
});
|
|
895
|
+
|
|
777
896
|
test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
|
|
778
897
|
// The first-created user with no vault pin is the admin posture.
|
|
779
898
|
const admin = await createUser(harness.db, "admin", "admin-passphrase", {
|
|
@@ -782,7 +901,7 @@ describe("handleAccountHomeGet", () => {
|
|
|
782
901
|
const session = createSession(harness.db, { userId: admin.id });
|
|
783
902
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
784
903
|
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
785
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
904
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
786
905
|
expect(res.status).toBe(200);
|
|
787
906
|
const html = await res.text();
|
|
788
907
|
expect(html).toContain("Welcome, admin");
|
|
@@ -813,8 +932,121 @@ describe("handleAccountHomeGet", () => {
|
|
|
813
932
|
harness.db.exec("PRAGMA foreign_keys = ON");
|
|
814
933
|
}
|
|
815
934
|
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
816
|
-
const res = handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
935
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
817
936
|
expect(res.status).toBe(302);
|
|
818
937
|
expect(res.headers.get("location")).toBe("/login");
|
|
819
938
|
});
|
|
939
|
+
|
|
940
|
+
test("renders the per-vault usage stat when usage resolves", async () => {
|
|
941
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
942
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
943
|
+
allowMulti: true,
|
|
944
|
+
passwordChanged: true,
|
|
945
|
+
assignedVaults: ["alice"],
|
|
946
|
+
});
|
|
947
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
948
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
949
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
950
|
+
const res = await handleAccountHomeGet(req, {
|
|
951
|
+
db: harness.db,
|
|
952
|
+
hubOrigin: HUB_ORIGIN,
|
|
953
|
+
resolveVaultPort: () => 1940,
|
|
954
|
+
// Stub the fetch: resolves to a known stat.
|
|
955
|
+
fetchUsage: async () => ({ notes: 7, totalBytes: 2 * 1024 * 1024 }),
|
|
956
|
+
});
|
|
957
|
+
expect(res.status).toBe(200);
|
|
958
|
+
const html = await res.text();
|
|
959
|
+
expect(html).toContain('data-testid="vault-usage"');
|
|
960
|
+
expect(html).toContain("7 notes · 2.0 MB");
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("omits the usage stat gracefully when the fetch fails (null)", async () => {
|
|
964
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
965
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
966
|
+
allowMulti: true,
|
|
967
|
+
passwordChanged: true,
|
|
968
|
+
assignedVaults: ["alice"],
|
|
969
|
+
});
|
|
970
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
971
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
972
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
973
|
+
const res = await handleAccountHomeGet(req, {
|
|
974
|
+
db: harness.db,
|
|
975
|
+
hubOrigin: HUB_ORIGIN,
|
|
976
|
+
resolveVaultPort: () => 1940,
|
|
977
|
+
fetchUsage: async () => null,
|
|
978
|
+
});
|
|
979
|
+
expect(res.status).toBe(200);
|
|
980
|
+
const html = await res.text();
|
|
981
|
+
// Tile still renders; just no usage stat.
|
|
982
|
+
expect(html).toContain("<strong>alice</strong>");
|
|
983
|
+
expect(html).not.toContain('data-testid="vault-usage"');
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test("renders the 'Advanced vault settings ↗' deep-link button for an assigned vault", async () => {
|
|
987
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
988
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
989
|
+
allowMulti: true,
|
|
990
|
+
passwordChanged: true,
|
|
991
|
+
assignedVaults: ["alice"],
|
|
992
|
+
});
|
|
993
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
994
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
995
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
996
|
+
const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
|
|
997
|
+
expect(res.status).toBe(200);
|
|
998
|
+
const html = await res.text();
|
|
999
|
+
expect(html).toContain('data-testid="vault-admin-button"');
|
|
1000
|
+
expect(html).toContain("Advanced vault settings");
|
|
1001
|
+
expect(html).toContain('action="/account/vault-admin-token/alice"');
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("renders the backup-state line when the mirror status resolves enabled", async () => {
|
|
1005
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
1006
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
1007
|
+
allowMulti: true,
|
|
1008
|
+
passwordChanged: true,
|
|
1009
|
+
assignedVaults: ["alice"],
|
|
1010
|
+
});
|
|
1011
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
1012
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
1013
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
1014
|
+
const res = await handleAccountHomeGet(req, {
|
|
1015
|
+
db: harness.db,
|
|
1016
|
+
hubOrigin: HUB_ORIGIN,
|
|
1017
|
+
resolveVaultPort: () => 1940,
|
|
1018
|
+
// Stub the mirror fetch: resolves to a backed-up, GitHub-pushing config.
|
|
1019
|
+
fetchMirror: async () => ({ enabled: true, backedUpToRemote: true }),
|
|
1020
|
+
});
|
|
1021
|
+
expect(res.status).toBe(200);
|
|
1022
|
+
const html = await res.text();
|
|
1023
|
+
expect(html).toContain('data-testid="backup-state-line"');
|
|
1024
|
+
// Already pushing → the handler threads mirrorPushing=true, so the
|
|
1025
|
+
// "Back up to GitHub ↗" action is suppressed.
|
|
1026
|
+
expect(html).not.toContain('data-testid="backup-github-button"');
|
|
1027
|
+
expect(html).toContain("version history + GitHub");
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("omits the backup line gracefully when the mirror fetch fails (null)", async () => {
|
|
1031
|
+
await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
|
|
1032
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", {
|
|
1033
|
+
allowMulti: true,
|
|
1034
|
+
passwordChanged: true,
|
|
1035
|
+
assignedVaults: ["alice"],
|
|
1036
|
+
});
|
|
1037
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
1038
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
1039
|
+
const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
|
|
1040
|
+
const res = await handleAccountHomeGet(req, {
|
|
1041
|
+
db: harness.db,
|
|
1042
|
+
hubOrigin: HUB_ORIGIN,
|
|
1043
|
+
resolveVaultPort: () => 1940,
|
|
1044
|
+
fetchMirror: async () => null,
|
|
1045
|
+
});
|
|
1046
|
+
expect(res.status).toBe(200);
|
|
1047
|
+
const html = await res.text();
|
|
1048
|
+
// Tile still renders; just no backup line.
|
|
1049
|
+
expect(html).toContain("<strong>alice</strong>");
|
|
1050
|
+
expect(html).not.toContain('data-testid="backup-state-line"');
|
|
1051
|
+
});
|
|
820
1052
|
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API tests for `/api/invites*` (`api-invites.ts`).
|
|
3
|
+
*
|
|
4
|
+
* - host:admin gate (403 without the scope)
|
|
5
|
+
* - POST create → 201 with single-emit token + URL; defaults applied
|
|
6
|
+
* - GET list → status-annotated, raw token NEVER present
|
|
7
|
+
* - DELETE /:id → revoke; 409 when already terminal; 404 unknown
|
|
8
|
+
*/
|
|
9
|
+
import type { Database } from "bun:sqlite";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "../api-invites.ts";
|
|
15
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
17
|
+
import { createUser } from "../users.ts";
|
|
18
|
+
|
|
19
|
+
const ISSUER = "https://hub.test";
|
|
20
|
+
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
21
|
+
|
|
22
|
+
interface Harness {
|
|
23
|
+
db: Database;
|
|
24
|
+
manifestPath: string;
|
|
25
|
+
cleanup: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeHarness(): Harness {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-invites-"));
|
|
30
|
+
const db = openHubDb(hubDbPath(dir));
|
|
31
|
+
const manifestPath = join(dir, "services.json");
|
|
32
|
+
writeFileSync(manifestPath, JSON.stringify({ services: [] }));
|
|
33
|
+
return {
|
|
34
|
+
db,
|
|
35
|
+
manifestPath,
|
|
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 makeAdminBearer(scopes = [HOST_ADMIN_SCOPE]): Promise<string> {
|
|
52
|
+
const user = await createUser(harness.db, "operator", "operator-password-1", {
|
|
53
|
+
allowMulti: true,
|
|
54
|
+
passwordChanged: true,
|
|
55
|
+
});
|
|
56
|
+
const minted = await signAccessToken(harness.db, {
|
|
57
|
+
sub: user.id,
|
|
58
|
+
scopes,
|
|
59
|
+
audience: "hub",
|
|
60
|
+
clientId: "parachute-hub-spa",
|
|
61
|
+
issuer: ISSUER,
|
|
62
|
+
ttlSeconds: 600,
|
|
63
|
+
});
|
|
64
|
+
return minted.token;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deps() {
|
|
68
|
+
return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
|
|
72
|
+
const headers = new Headers(init.headers ?? {});
|
|
73
|
+
headers.set("authorization", `Bearer ${bearer}`);
|
|
74
|
+
return new Request(`${ISSUER}${path}`, { ...init, headers });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("/api/invites auth", () => {
|
|
78
|
+
test("403 without host:admin scope", async () => {
|
|
79
|
+
const bearer = await makeAdminBearer(["other:scope"]);
|
|
80
|
+
const res = await handleListInvites(withBearer("/api/invites", bearer), deps());
|
|
81
|
+
expect(res.status).toBe(403);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("POST /api/invites", () => {
|
|
86
|
+
test("201 with single-emit token + URL; defaults (write/provision/7d)", async () => {
|
|
87
|
+
const bearer = await makeAdminBearer();
|
|
88
|
+
const res = await handleCreateInvite(
|
|
89
|
+
withBearer("/api/invites", bearer, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "content-type": "application/json" },
|
|
92
|
+
body: JSON.stringify({}),
|
|
93
|
+
}),
|
|
94
|
+
deps(),
|
|
95
|
+
);
|
|
96
|
+
expect(res.status).toBe(201);
|
|
97
|
+
const body = (await res.json()) as {
|
|
98
|
+
invite: { id: string; status: string; role: string; provision_vault: boolean };
|
|
99
|
+
token: string;
|
|
100
|
+
url: string;
|
|
101
|
+
};
|
|
102
|
+
expect(body.token.length).toBeGreaterThan(40);
|
|
103
|
+
expect(body.url).toBe(`${ISSUER}/account/setup/${body.token}`);
|
|
104
|
+
expect(body.invite.role).toBe("write");
|
|
105
|
+
expect(body.invite.provision_vault).toBe(true);
|
|
106
|
+
expect(body.invite.status).toBe("pending");
|
|
107
|
+
// The id is the sha256 hash — never the raw token.
|
|
108
|
+
expect(body.invite.id).not.toBe(body.token);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("400 on a bad role", async () => {
|
|
112
|
+
const bearer = await makeAdminBearer();
|
|
113
|
+
const res = await handleCreateInvite(
|
|
114
|
+
withBearer("/api/invites", bearer, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ role: "admin" }),
|
|
118
|
+
}),
|
|
119
|
+
deps(),
|
|
120
|
+
);
|
|
121
|
+
expect(res.status).toBe(400);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("400 rejects a shared-vault invite (provision_vault=false + vault_name)", async () => {
|
|
125
|
+
// Defense in depth (FIX-1): assigning a redeemer to a PRE-EXISTING vault
|
|
126
|
+
// as owner-admin is a cross-tenant breach; shared-vault invites aren't
|
|
127
|
+
// supported yet, so the create handler refuses this combination outright.
|
|
128
|
+
const bearer = await makeAdminBearer();
|
|
129
|
+
const res = await handleCreateInvite(
|
|
130
|
+
withBearer("/api/invites", bearer, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ provision_vault: false, vault_name: "someoneelse" }),
|
|
134
|
+
}),
|
|
135
|
+
deps(),
|
|
136
|
+
);
|
|
137
|
+
expect(res.status).toBe(400);
|
|
138
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
139
|
+
expect(body.error).toBe("invalid_request");
|
|
140
|
+
expect(body.error_description).toContain("shared-vault");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("account-only invite (provision_vault=false, NO vault_name) is still allowed", async () => {
|
|
144
|
+
const bearer = await makeAdminBearer();
|
|
145
|
+
const res = await handleCreateInvite(
|
|
146
|
+
withBearer("/api/invites", bearer, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ provision_vault: false }),
|
|
150
|
+
}),
|
|
151
|
+
deps(),
|
|
152
|
+
);
|
|
153
|
+
expect(res.status).toBe(201);
|
|
154
|
+
const body = (await res.json()) as {
|
|
155
|
+
invite: { provision_vault: boolean; vault_name: string | null };
|
|
156
|
+
};
|
|
157
|
+
expect(body.invite.provision_vault).toBe(false);
|
|
158
|
+
expect(body.invite.vault_name).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("GET /api/invites", () => {
|
|
163
|
+
test("lists invites; raw token NEVER present in the wire shape", async () => {
|
|
164
|
+
const bearer = await makeAdminBearer();
|
|
165
|
+
const created = await handleCreateInvite(
|
|
166
|
+
withBearer("/api/invites", bearer, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "content-type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ vault_name: "maya" }),
|
|
170
|
+
}),
|
|
171
|
+
deps(),
|
|
172
|
+
);
|
|
173
|
+
const createdBody = (await created.json()) as { token: string };
|
|
174
|
+
const list = await handleListInvites(withBearer("/api/invites", bearer), deps());
|
|
175
|
+
const body = (await list.json()) as { invites: { id: string }[] };
|
|
176
|
+
expect(body.invites.length).toBe(1);
|
|
177
|
+
// The raw token must not be recoverable from the list.
|
|
178
|
+
const json = JSON.stringify(body);
|
|
179
|
+
expect(json).not.toContain(createdBody.token);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("DELETE /api/invites/:id", () => {
|
|
184
|
+
test("revokes a pending invite; 409 if already revoked; 404 unknown", async () => {
|
|
185
|
+
const bearer = await makeAdminBearer();
|
|
186
|
+
const created = await handleCreateInvite(
|
|
187
|
+
withBearer("/api/invites", bearer, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "content-type": "application/json" },
|
|
190
|
+
body: JSON.stringify({}),
|
|
191
|
+
}),
|
|
192
|
+
deps(),
|
|
193
|
+
);
|
|
194
|
+
const { invite } = (await created.json()) as { invite: { id: string } };
|
|
195
|
+
|
|
196
|
+
const ok = await handleRevokeInvite(
|
|
197
|
+
withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
|
|
198
|
+
invite.id,
|
|
199
|
+
deps(),
|
|
200
|
+
);
|
|
201
|
+
expect(ok.status).toBe(200);
|
|
202
|
+
|
|
203
|
+
const again = await handleRevokeInvite(
|
|
204
|
+
withBearer(`/api/invites/${invite.id}`, bearer, { method: "DELETE" }),
|
|
205
|
+
invite.id,
|
|
206
|
+
deps(),
|
|
207
|
+
);
|
|
208
|
+
expect(again.status).toBe(409);
|
|
209
|
+
|
|
210
|
+
const unknown = await handleRevokeInvite(
|
|
211
|
+
withBearer("/api/invites/deadbeef", bearer, { method: "DELETE" }),
|
|
212
|
+
"deadbeef",
|
|
213
|
+
deps(),
|
|
214
|
+
);
|
|
215
|
+
expect(unknown.status).toBe(404);
|
|
216
|
+
});
|
|
217
|
+
});
|