@openparachute/hub 0.5.10-rc.9 → 0.5.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 -1
- package/src/__tests__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +74 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +216 -3
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +15 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +134 -16
- package/src/users.ts +210 -3
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
|
@@ -4,15 +4,21 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
6
|
import {
|
|
7
|
+
PASSWORD_MIN_LEN,
|
|
7
8
|
SingleUserModeError,
|
|
9
|
+
USERNAME_RESERVED,
|
|
8
10
|
UserNotFoundError,
|
|
9
11
|
UsernameTakenError,
|
|
10
12
|
createUser,
|
|
13
|
+
deleteUser,
|
|
11
14
|
getUserById,
|
|
12
15
|
getUserByUsername,
|
|
16
|
+
getUserByUsernameCI,
|
|
13
17
|
listUsers,
|
|
14
18
|
setPassword,
|
|
15
19
|
userCount,
|
|
20
|
+
validatePassword,
|
|
21
|
+
validateUsername,
|
|
16
22
|
verifyPassword,
|
|
17
23
|
} from "../users.ts";
|
|
18
24
|
|
|
@@ -29,6 +35,11 @@ function makeDb() {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
describe("createUser", () => {
|
|
38
|
+
// Note: createUser doesn't call validatePassword directly (PR 2 wires
|
|
39
|
+
// it at the endpoint layer). These short passwords ("hunter2", "pw1",
|
|
40
|
+
// etc.) are deliberate for testing the argon2id round-trip + the
|
|
41
|
+
// INSERT shape in isolation; PR 2's endpoint tests will use full-
|
|
42
|
+
// length (12+ char) passwords against the validator-gated path.
|
|
32
43
|
test("creates a user and stores an argon2id hash", async () => {
|
|
33
44
|
const { db, cleanup } = makeDb();
|
|
34
45
|
try {
|
|
@@ -39,6 +50,50 @@ describe("createUser", () => {
|
|
|
39
50
|
expect(u.passwordHash.startsWith("$argon2id$")).toBe(true);
|
|
40
51
|
expect(u.createdAt).toBe(u.updatedAt);
|
|
41
52
|
expect(userCount(db)).toBe(1);
|
|
53
|
+
// Default multi-user-Phase-1 shape: unchanged password, no vault pin.
|
|
54
|
+
expect(u.passwordChanged).toBe(false);
|
|
55
|
+
expect(u.assignedVault).toBeNull();
|
|
56
|
+
} finally {
|
|
57
|
+
cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("passwordChanged opt-in lands the bit set (wizard / env-seed path)", async () => {
|
|
62
|
+
const { db, cleanup } = makeDb();
|
|
63
|
+
try {
|
|
64
|
+
const u = await createUser(db, "owner", "hunter2", { passwordChanged: true });
|
|
65
|
+
expect(u.passwordChanged).toBe(true);
|
|
66
|
+
// Round-trip through getUserById so we know it's persisted, not
|
|
67
|
+
// just returned by the in-memory createUser result.
|
|
68
|
+
const fresh = getUserById(db, u.id);
|
|
69
|
+
expect(fresh?.passwordChanged).toBe(true);
|
|
70
|
+
} finally {
|
|
71
|
+
cleanup();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("assignedVault opt-in persists the column (admin-creates-user path)", async () => {
|
|
76
|
+
const { db, cleanup } = makeDb();
|
|
77
|
+
try {
|
|
78
|
+
const u = await createUser(db, "alice", "pw1", {
|
|
79
|
+
allowMulti: true,
|
|
80
|
+
assignedVault: "alice",
|
|
81
|
+
});
|
|
82
|
+
expect(u.assignedVault).toBe("alice");
|
|
83
|
+
const fresh = getUserById(db, u.id);
|
|
84
|
+
expect(fresh?.assignedVault).toBe("alice");
|
|
85
|
+
} finally {
|
|
86
|
+
cleanup();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("assignedVault explicit null is treated the same as omitted", async () => {
|
|
91
|
+
const { db, cleanup } = makeDb();
|
|
92
|
+
try {
|
|
93
|
+
const u = await createUser(db, "admin1", "pw1", { assignedVault: null });
|
|
94
|
+
expect(u.assignedVault).toBeNull();
|
|
95
|
+
const fresh = getUserById(db, u.id);
|
|
96
|
+
expect(fresh?.assignedVault).toBeNull();
|
|
42
97
|
} finally {
|
|
43
98
|
cleanup();
|
|
44
99
|
}
|
|
@@ -152,3 +207,144 @@ describe("listUsers / getUserByUsername", () => {
|
|
|
152
207
|
}
|
|
153
208
|
});
|
|
154
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("getUserByUsernameCI", () => {
|
|
212
|
+
test("matches exact lowercase username", async () => {
|
|
213
|
+
const { db, cleanup } = makeDb();
|
|
214
|
+
try {
|
|
215
|
+
await createUser(db, "alice", "alice-strong-passphrase");
|
|
216
|
+
expect(getUserByUsernameCI(db, "alice")?.username).toBe("alice");
|
|
217
|
+
} finally {
|
|
218
|
+
cleanup();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("matches case-insensitively (defense in depth for legacy mixed-case rows)", async () => {
|
|
223
|
+
const { db, cleanup } = makeDb();
|
|
224
|
+
try {
|
|
225
|
+
await createUser(db, "alice", "alice-strong-passphrase");
|
|
226
|
+
expect(getUserByUsernameCI(db, "Alice")?.username).toBe("alice");
|
|
227
|
+
expect(getUserByUsernameCI(db, "ALICE")?.username).toBe("alice");
|
|
228
|
+
} finally {
|
|
229
|
+
cleanup();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("returns null when no row matches", async () => {
|
|
234
|
+
const { db, cleanup } = makeDb();
|
|
235
|
+
try {
|
|
236
|
+
expect(getUserByUsernameCI(db, "ghost")).toBeNull();
|
|
237
|
+
} finally {
|
|
238
|
+
cleanup();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("deleteUser", () => {
|
|
244
|
+
test("returns false when user does not exist", () => {
|
|
245
|
+
const { db, cleanup } = makeDb();
|
|
246
|
+
try {
|
|
247
|
+
expect(deleteUser(db, "no-such-id")).toBe(false);
|
|
248
|
+
} finally {
|
|
249
|
+
cleanup();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("returns true and drops the row", async () => {
|
|
254
|
+
const { db, cleanup } = makeDb();
|
|
255
|
+
try {
|
|
256
|
+
const u = await createUser(db, "alice", "alice-strong-passphrase");
|
|
257
|
+
expect(deleteUser(db, u.id)).toBe(true);
|
|
258
|
+
expect(getUserById(db, u.id)).toBeNull();
|
|
259
|
+
expect(userCount(db)).toBe(0);
|
|
260
|
+
} finally {
|
|
261
|
+
cleanup();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("validateUsername", () => {
|
|
267
|
+
test("happy path — typical names", () => {
|
|
268
|
+
for (const name of ["alice", "bob_42", "user-1", "ab", "a-b-c", "x_y_z"]) {
|
|
269
|
+
const r = validateUsername(name);
|
|
270
|
+
expect(r.valid).toBe(true);
|
|
271
|
+
if (r.valid) expect(r.name).toBe(name);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("length boundaries — 2 and 32 OK, 1 and 33 rejected", () => {
|
|
276
|
+
expect(validateUsername("ab").valid).toBe(true);
|
|
277
|
+
expect(validateUsername("a".repeat(32)).valid).toBe(true);
|
|
278
|
+
const tooShort = validateUsername("a");
|
|
279
|
+
expect(tooShort.valid).toBe(false);
|
|
280
|
+
if (!tooShort.valid) expect(tooShort.reason).toBe("length");
|
|
281
|
+
const tooLong = validateUsername("a".repeat(33));
|
|
282
|
+
expect(tooLong.valid).toBe(false);
|
|
283
|
+
if (!tooLong.valid) expect(tooLong.reason).toBe("length");
|
|
284
|
+
// Empty string is a length failure (not a format failure).
|
|
285
|
+
const empty = validateUsername("");
|
|
286
|
+
expect(empty.valid).toBe(false);
|
|
287
|
+
if (!empty.valid) expect(empty.reason).toBe("length");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("format failures — uppercase, spaces, symbols rejected", () => {
|
|
291
|
+
for (const name of ["Alice", "bob smith", "user@domain", "name!", "user.name", "naïve"]) {
|
|
292
|
+
const r = validateUsername(name);
|
|
293
|
+
expect(r.valid).toBe(false);
|
|
294
|
+
if (!r.valid) expect(r.reason).toBe("format");
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("reserved words rejected — case-insensitive", () => {
|
|
299
|
+
for (const reserved of USERNAME_RESERVED) {
|
|
300
|
+
const r = validateUsername(reserved);
|
|
301
|
+
expect(r.valid).toBe(false);
|
|
302
|
+
if (!r.valid) expect(r.reason).toBe("reserved");
|
|
303
|
+
}
|
|
304
|
+
// Case variants — the regex pins lowercase so uppercase fails as
|
|
305
|
+
// "format" first; the case-insensitive reserved check is defense in
|
|
306
|
+
// depth. But within the lowercase-allowed set, mixed-case-spelled
|
|
307
|
+
// reserved words are blocked by the format gate (e.g. "Admin"
|
|
308
|
+
// fails format, not reserved). The regex catches case variants
|
|
309
|
+
// before reserved-check ever runs — that's correct order.
|
|
310
|
+
const mixed = validateUsername("Admin");
|
|
311
|
+
expect(mixed.valid).toBe(false);
|
|
312
|
+
if (!mixed.valid) expect(mixed.reason).toBe("format");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("hyphens and underscores allowed; numbers allowed", () => {
|
|
316
|
+
expect(validateUsername("user_1").valid).toBe(true);
|
|
317
|
+
expect(validateUsername("user-2").valid).toBe(true);
|
|
318
|
+
expect(validateUsername("123").valid).toBe(true);
|
|
319
|
+
expect(validateUsername("_-_").valid).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("validatePassword", () => {
|
|
324
|
+
test("happy path — 12+ chars accepted", () => {
|
|
325
|
+
expect(validatePassword("twelvechars1").valid).toBe(true);
|
|
326
|
+
expect(validatePassword("a much longer passphrase here").valid).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("boundary — exactly 12 chars accepted, 11 rejected", () => {
|
|
330
|
+
expect(PASSWORD_MIN_LEN).toBe(12);
|
|
331
|
+
expect(validatePassword("a".repeat(12)).valid).toBe(true);
|
|
332
|
+
const r = validatePassword("a".repeat(11));
|
|
333
|
+
expect(r.valid).toBe(false);
|
|
334
|
+
if (!r.valid) expect(r.reason).toBe("too_short");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("empty string rejected as too_short", () => {
|
|
338
|
+
const r = validatePassword("");
|
|
339
|
+
expect(r.valid).toBe(false);
|
|
340
|
+
if (!r.valid) expect(r.reason).toBe("too_short");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("no complexity rules — long but all-same-char accepted", () => {
|
|
344
|
+
// Phase 1 takes NIST 800-63B's lead: length over forced classes.
|
|
345
|
+
// Aaron settled on 12-min, no complexity. If we later want to nudge
|
|
346
|
+
// toward passphrases we'll layer it as a separate signal, not a
|
|
347
|
+
// hard gate.
|
|
348
|
+
expect(validatePassword("aaaaaaaaaaaa").valid).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared `vault-names.ts` helper (multi-user Phase 1, PR 4).
|
|
3
|
+
*
|
|
4
|
+
* The shared helper lifted the two pre-PR-4 private copies (`oauth-handlers.ts`
|
|
5
|
+
* + `api-users.ts`) into one place. These tests pin the canonical behavior so
|
|
6
|
+
* both callers — the OAuth consent picker + the admin SPA's assigned-vault
|
|
7
|
+
* dropdown + the server-side defense in `handleConsentSubmit` — see the same
|
|
8
|
+
* name set.
|
|
9
|
+
*/
|
|
10
|
+
import { 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 type { ServicesManifest } from "../services-manifest.ts";
|
|
15
|
+
import { listVaultNames, listVaultNamesFromPath } from "../vault-names.ts";
|
|
16
|
+
|
|
17
|
+
describe("listVaultNames", () => {
|
|
18
|
+
test("returns empty list when no vault services are registered", () => {
|
|
19
|
+
const manifest: ServicesManifest = {
|
|
20
|
+
services: [
|
|
21
|
+
{ name: "parachute-notes", port: 1942, paths: ["/notes"], health: "/h", version: "0.1.0" },
|
|
22
|
+
{
|
|
23
|
+
name: "parachute-scribe",
|
|
24
|
+
port: 1943,
|
|
25
|
+
paths: ["/scribe"],
|
|
26
|
+
health: "/h",
|
|
27
|
+
version: "0.1.0",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
expect(listVaultNames(manifest)).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("single-entry-multi-path: emits one name per `/vault/<name>` path", () => {
|
|
35
|
+
const manifest: ServicesManifest = {
|
|
36
|
+
services: [
|
|
37
|
+
{
|
|
38
|
+
name: "parachute-vault",
|
|
39
|
+
port: 1940,
|
|
40
|
+
paths: ["/vault/work", "/vault/personal"],
|
|
41
|
+
health: "/h",
|
|
42
|
+
version: "0.1.0",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("per-vault entries: emits one name per `parachute-vault-<name>` entry", () => {
|
|
50
|
+
const manifest: ServicesManifest = {
|
|
51
|
+
services: [
|
|
52
|
+
{
|
|
53
|
+
name: "parachute-vault-work",
|
|
54
|
+
port: 1940,
|
|
55
|
+
paths: ["/vault/work"],
|
|
56
|
+
health: "/h",
|
|
57
|
+
version: "0.1.0",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "parachute-vault-personal",
|
|
61
|
+
port: 1941,
|
|
62
|
+
paths: ["/vault/personal"],
|
|
63
|
+
health: "/h",
|
|
64
|
+
version: "0.1.0",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
expect(listVaultNames(manifest)).toEqual(["personal", "work"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("entry with no paths falls back to the manifest-suffix name (hub#143)", () => {
|
|
72
|
+
const manifest: ServicesManifest = {
|
|
73
|
+
services: [
|
|
74
|
+
{
|
|
75
|
+
name: "parachute-vault-archived",
|
|
76
|
+
port: 1940,
|
|
77
|
+
paths: [],
|
|
78
|
+
health: "/h",
|
|
79
|
+
version: "0.1.0",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
expect(listVaultNames(manifest)).toEqual(["archived"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("deduplicates collisions across single-entry + per-vault shapes", () => {
|
|
87
|
+
const manifest: ServicesManifest = {
|
|
88
|
+
services: [
|
|
89
|
+
{
|
|
90
|
+
name: "parachute-vault",
|
|
91
|
+
port: 1940,
|
|
92
|
+
paths: ["/vault/work"],
|
|
93
|
+
health: "/h",
|
|
94
|
+
version: "0.1.0",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "parachute-vault-work",
|
|
98
|
+
port: 1941,
|
|
99
|
+
paths: ["/vault/work"],
|
|
100
|
+
health: "/h",
|
|
101
|
+
version: "0.1.0",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
expect(listVaultNames(manifest)).toEqual(["work"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("output is sorted ascending — deterministic order for both callers", () => {
|
|
109
|
+
const manifest: ServicesManifest = {
|
|
110
|
+
services: [
|
|
111
|
+
{
|
|
112
|
+
name: "parachute-vault",
|
|
113
|
+
port: 1940,
|
|
114
|
+
paths: ["/vault/zeta", "/vault/alpha", "/vault/middle"],
|
|
115
|
+
health: "/h",
|
|
116
|
+
version: "0.1.0",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
expect(listVaultNames(manifest)).toEqual(["alpha", "middle", "zeta"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("listVaultNamesFromPath", () => {
|
|
125
|
+
test("reads from a services.json file and emits the same names", () => {
|
|
126
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-vault-names-"));
|
|
127
|
+
const path = join(dir, "services.json");
|
|
128
|
+
writeFileSync(
|
|
129
|
+
path,
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
services: [
|
|
132
|
+
{
|
|
133
|
+
name: "parachute-vault",
|
|
134
|
+
port: 1940,
|
|
135
|
+
paths: ["/vault/work"],
|
|
136
|
+
health: "/h",
|
|
137
|
+
version: "0.1.0",
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
try {
|
|
143
|
+
expect(listVaultNamesFromPath(path)).toEqual(["work"]);
|
|
144
|
+
} finally {
|
|
145
|
+
rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("api-users.ts and oauth-handlers.ts read the same source through listVaultNamesFromPath / listVaultNames", () => {
|
|
150
|
+
// Cross-caller parity: the helper is the single source. Both code paths
|
|
151
|
+
// should see byte-identical output against the same services.json.
|
|
152
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-vault-names-parity-"));
|
|
153
|
+
const path = join(dir, "services.json");
|
|
154
|
+
const manifest: ServicesManifest = {
|
|
155
|
+
services: [
|
|
156
|
+
{
|
|
157
|
+
name: "parachute-vault",
|
|
158
|
+
port: 1940,
|
|
159
|
+
paths: ["/vault/work", "/vault/personal"],
|
|
160
|
+
health: "/h",
|
|
161
|
+
version: "0.1.0",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
writeFileSync(path, JSON.stringify(manifest));
|
|
166
|
+
try {
|
|
167
|
+
expect(listVaultNamesFromPath(path)).toEqual(listVaultNames(manifest));
|
|
168
|
+
} finally {
|
|
169
|
+
rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|