@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- 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.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-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- 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/web/ui/dist/assets/index-C-XzMVqN.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
|
@@ -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
|
|
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
|
+
});
|