@openparachute/hub 0.6.5-rc.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-db-liveness.test.ts +12 -7
  12. package/src/__tests__/hub-server.test.ts +319 -21
  13. package/src/__tests__/invites.test.ts +27 -0
  14. package/src/__tests__/module-manifest.test.ts +305 -8
  15. package/src/__tests__/serve-boot.test.ts +133 -2
  16. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  17. package/src/__tests__/setup-gate.test.ts +13 -7
  18. package/src/__tests__/setup-wizard.test.ts +228 -1
  19. package/src/__tests__/vault-name.test.ts +20 -5
  20. package/src/__tests__/well-known.test.ts +44 -8
  21. package/src/account-vault-admin-token.ts +43 -14
  22. package/src/admin-channel-token.ts +135 -0
  23. package/src/admin-connections.ts +980 -0
  24. package/src/admin-module-token.ts +197 -0
  25. package/src/admin-vaults.ts +390 -12
  26. package/src/api-hub-upgrade.ts +4 -3
  27. package/src/api-modules-ops.ts +41 -16
  28. package/src/api-modules.ts +238 -116
  29. package/src/api-tokens.ts +8 -5
  30. package/src/commands/serve-boot.ts +80 -3
  31. package/src/commands/setup.ts +4 -4
  32. package/src/connections-store.ts +161 -0
  33. package/src/grants.ts +50 -0
  34. package/src/hub-db-liveness.ts +33 -17
  35. package/src/hub-server.ts +354 -61
  36. package/src/invites.ts +22 -0
  37. package/src/jwt-sign.ts +41 -1
  38. package/src/module-manifest.ts +429 -23
  39. package/src/origin-check.ts +106 -0
  40. package/src/proxy-error-ui.ts +1 -1
  41. package/src/service-spec.ts +132 -41
  42. package/src/setup-wizard.ts +68 -6
  43. package/src/users.ts +11 -0
  44. package/src/vault-name.ts +27 -7
  45. package/src/well-known.ts +41 -33
  46. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  47. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  48. package/web/ui/dist/index.html +2 -2
  49. package/src/__tests__/api-modules-config.test.ts +0 -882
  50. package/src/api-modules-config.ts +0 -421
  51. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  52. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.5-rc.7",
3
+ "version": "0.7.0",
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": {
@@ -527,6 +527,40 @@ describe("POST /account/setup/<token> — vault-name validation (N1)", () => {
527
527
  // No account created.
528
528
  expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
529
529
  });
530
+
531
+ test("an invitee-chosen RESERVED vault name (list/new/assets/admin) → 400, never provisioned (B2h)", async () => {
532
+ // Pre-consolidation, the invite path's validator reserved only "list" —
533
+ // a non-admin invite redeemer could squat "admin" and capture the
534
+ // daemon-level /vault/admin mount. One consolidated set closes that.
535
+ const admin = await createUser(harness.db, "operator", "operator-password-1");
536
+ for (const name of ["list", "new", "assets", "admin"]) {
537
+ // vault_name null → the redeemer names their own vault.
538
+ const { rawToken } = issueInvite(harness.db, { createdBy: admin.id });
539
+ const { token: csrfToken, cookieFragment } = csrfPair();
540
+ const stub = makeStubRunCommand();
541
+ const res = await handleAccountSetupPost(
542
+ postReq(
543
+ rawToken,
544
+ {
545
+ [CSRF_FIELD_NAME]: csrfToken,
546
+ username: "sam",
547
+ password: "sam-strong-password-12",
548
+ password_confirm: "sam-strong-password-12",
549
+ vault_name: name,
550
+ },
551
+ cookieFragment,
552
+ ),
553
+ rawToken,
554
+ deps(stub.run),
555
+ );
556
+ expect(res.status).toBe(400);
557
+ const html = await res.text();
558
+ expect(html).toContain("reserved");
559
+ // The vault CLI is never reached; no account created.
560
+ expect(stub.calls.length).toBe(0);
561
+ expect(getUserByUsernameCI(harness.db, "sam")).toBeNull();
562
+ }
563
+ });
530
564
  });
531
565
 
532
566
  describe("POST /account/setup/<token> — concurrent redeem (N2)", () => {
@@ -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 /admin/ → lands on the vault admin SPA home.
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 (e.g. a custom admin path)", async () => {
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: "/manage/" }),
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
+ });