@openparachute/hub 0.5.13 → 0.5.14-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/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for `/api/users*` (multi-user Phase 1
|
|
2
|
+
* Tests for `/api/users*` (multi-user Phase 1 PR 2 + Phase 2 PR 1).
|
|
3
|
+
* Covers:
|
|
3
4
|
*
|
|
4
5
|
* - Auth boundary: every endpoint requires a bearer carrying
|
|
5
6
|
* `parachute:host:admin`.
|
|
@@ -11,6 +12,10 @@
|
|
|
11
12
|
* 400 `assigned_vault_not_found`).
|
|
12
13
|
* - DELETE happy path with token revocation; first-admin-undeletable
|
|
13
14
|
* returns 403; 404 on unknown id.
|
|
15
|
+
* - POST /:id/reset-password (Phase 2 PR 1) happy path with token
|
|
16
|
+
* revocation, first-admin protection (403 cannot_reset_first_admin),
|
|
17
|
+
* password-validation branches (too-short 400, too-long 413 before
|
|
18
|
+
* argon2id), missing target (404), auth boundary.
|
|
14
19
|
* - GET /api/users/vaults returns the same name set the OAuth issuer
|
|
15
20
|
* would resolve against.
|
|
16
21
|
* - 405 on wrong methods.
|
|
@@ -25,10 +30,12 @@ import {
|
|
|
25
30
|
handleDeleteUser,
|
|
26
31
|
handleListUsers,
|
|
27
32
|
handleListVaults,
|
|
33
|
+
handleResetUserPassword,
|
|
34
|
+
handleUpdateUserVaults,
|
|
28
35
|
} from "../api-users.ts";
|
|
29
36
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
30
37
|
import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
31
|
-
import { createUser } from "../users.ts";
|
|
38
|
+
import { createUser, getUserById, verifyPassword } from "../users.ts";
|
|
32
39
|
|
|
33
40
|
const ISSUER = "https://hub.test";
|
|
34
41
|
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
@@ -170,7 +177,7 @@ describe("handleListUsers", () => {
|
|
|
170
177
|
expect(u).toHaveProperty("id");
|
|
171
178
|
expect(u).toHaveProperty("username");
|
|
172
179
|
expect(u).toHaveProperty("password_changed");
|
|
173
|
-
expect(u).toHaveProperty("
|
|
180
|
+
expect(u).toHaveProperty("assigned_vaults");
|
|
174
181
|
expect(u).toHaveProperty("created_at");
|
|
175
182
|
}
|
|
176
183
|
});
|
|
@@ -214,13 +221,13 @@ describe("handleCreateUser", () => {
|
|
|
214
221
|
const res = await post(bearer, {
|
|
215
222
|
username: "alice",
|
|
216
223
|
password: "alice-strong-passphrase",
|
|
217
|
-
|
|
224
|
+
assignedVaults: [],
|
|
218
225
|
});
|
|
219
226
|
expect(res.status).toBe(201);
|
|
220
227
|
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
221
228
|
expect(body.user.username).toBe("alice");
|
|
222
229
|
expect(body.user.password_changed).toBe(false);
|
|
223
|
-
expect(body.user.
|
|
230
|
+
expect(body.user.assigned_vaults).toEqual([]);
|
|
224
231
|
expect(body.user).not.toHaveProperty("password_hash");
|
|
225
232
|
});
|
|
226
233
|
|
|
@@ -325,9 +332,9 @@ describe("handleCreateUser", () => {
|
|
|
325
332
|
const stamp = "2026-05-20T00:00:00.000Z";
|
|
326
333
|
harness.db
|
|
327
334
|
.prepare(
|
|
328
|
-
"INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed
|
|
335
|
+
"INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, ?)",
|
|
329
336
|
)
|
|
330
|
-
.run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1
|
|
337
|
+
.run("legacy-id", "Alice", "$argon2id$fake", stamp, stamp, 1);
|
|
331
338
|
const { bearer } = await makeAdminBearer();
|
|
332
339
|
const res = await post(bearer, {
|
|
333
340
|
username: "alice",
|
|
@@ -343,25 +350,55 @@ describe("handleCreateUser", () => {
|
|
|
343
350
|
const res = await post(bearer, {
|
|
344
351
|
username: "alice",
|
|
345
352
|
password: "alice-strong-passphrase",
|
|
346
|
-
|
|
353
|
+
assignedVaults: ["ghost-vault"],
|
|
347
354
|
});
|
|
348
355
|
expect(res.status).toBe(400);
|
|
349
356
|
const body = (await res.json()) as { error: string };
|
|
350
357
|
expect(body.error).toBe("assigned_vault_not_found");
|
|
351
358
|
});
|
|
352
359
|
|
|
353
|
-
test("happy path with
|
|
360
|
+
test("happy path with single assigned_vaults that exists in services.json", async () => {
|
|
354
361
|
harness.cleanup();
|
|
355
362
|
harness = makeHarness(manifestWithVaults("home"));
|
|
356
363
|
const { bearer } = await makeAdminBearer();
|
|
357
364
|
const res = await post(bearer, {
|
|
358
365
|
username: "alice",
|
|
359
366
|
password: "alice-strong-passphrase",
|
|
360
|
-
|
|
367
|
+
assignedVaults: ["home"],
|
|
361
368
|
});
|
|
362
369
|
expect(res.status).toBe(201);
|
|
363
370
|
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
364
|
-
expect(body.user.
|
|
371
|
+
expect(body.user.assigned_vaults).toEqual(["home"]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("happy path with multiple assigned_vaults (Phase 2 PR 2)", async () => {
|
|
375
|
+
harness.cleanup();
|
|
376
|
+
harness = makeHarness(manifestWithVaults("personal", "family"));
|
|
377
|
+
const { bearer } = await makeAdminBearer();
|
|
378
|
+
const res = await post(bearer, {
|
|
379
|
+
username: "alice",
|
|
380
|
+
password: "alice-strong-passphrase",
|
|
381
|
+
assignedVaults: ["personal", "family"],
|
|
382
|
+
});
|
|
383
|
+
expect(res.status).toBe(201);
|
|
384
|
+
const body = (await res.json()) as { user: Record<string, unknown> };
|
|
385
|
+
const list = body.user.assigned_vaults as string[];
|
|
386
|
+
expect(new Set(list)).toEqual(new Set(["personal", "family"]));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("400 assigned_vault_not_found when ANY vault in the array is unknown", async () => {
|
|
390
|
+
harness.cleanup();
|
|
391
|
+
harness = makeHarness(manifestWithVaults("home"));
|
|
392
|
+
const { bearer } = await makeAdminBearer();
|
|
393
|
+
const res = await post(bearer, {
|
|
394
|
+
username: "alice",
|
|
395
|
+
password: "alice-strong-passphrase",
|
|
396
|
+
assignedVaults: ["home", "ghost"],
|
|
397
|
+
});
|
|
398
|
+
expect(res.status).toBe(400);
|
|
399
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
400
|
+
expect(body.error).toBe("assigned_vault_not_found");
|
|
401
|
+
expect(body.error_description).toContain("ghost");
|
|
365
402
|
});
|
|
366
403
|
});
|
|
367
404
|
|
|
@@ -425,7 +462,7 @@ describe("handleDeleteUser", () => {
|
|
|
425
462
|
expect(list.users.map((u) => u.id)).toContain(userId);
|
|
426
463
|
});
|
|
427
464
|
|
|
428
|
-
test("
|
|
465
|
+
test("200 + revocation_lag_seconds deletes a non-first user and revokes their tokens", async () => {
|
|
429
466
|
const { bearer } = await makeAdminBearer();
|
|
430
467
|
// Create a second user (non-first) + mint a token on their behalf.
|
|
431
468
|
const second = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
@@ -460,7 +497,12 @@ describe("handleDeleteUser", () => {
|
|
|
460
497
|
second.id,
|
|
461
498
|
deps(),
|
|
462
499
|
);
|
|
463
|
-
|
|
500
|
+
// 200 + body (was a bare 204) so the SPA can warn about revocation lag —
|
|
501
|
+
// consistency with the reset-password path.
|
|
502
|
+
expect(res.status).toBe(200);
|
|
503
|
+
const body = (await res.json()) as { ok: boolean; revocation_lag_seconds: number };
|
|
504
|
+
expect(body.ok).toBe(true);
|
|
505
|
+
expect(body.revocation_lag_seconds).toBe(60);
|
|
464
506
|
|
|
465
507
|
// User row is gone.
|
|
466
508
|
const listRes = await handleListUsers(withBearer("/api/users", bearer), deps());
|
|
@@ -477,6 +519,214 @@ describe("handleDeleteUser", () => {
|
|
|
477
519
|
});
|
|
478
520
|
});
|
|
479
521
|
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// POST /api/users/:id/reset-password — admin-initiated password reset
|
|
524
|
+
// (multi-user Phase 2 PR 1)
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
describe("handleResetUserPassword", () => {
|
|
528
|
+
async function post(
|
|
529
|
+
bearer: string | null,
|
|
530
|
+
id: string,
|
|
531
|
+
body: Record<string, unknown> | string | null,
|
|
532
|
+
headers: Record<string, string> = {},
|
|
533
|
+
): Promise<Response> {
|
|
534
|
+
const init: RequestInit = {
|
|
535
|
+
method: "POST",
|
|
536
|
+
headers: {
|
|
537
|
+
"content-type": "application/json",
|
|
538
|
+
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
|
|
539
|
+
...headers,
|
|
540
|
+
},
|
|
541
|
+
body: body === null ? undefined : typeof body === "string" ? body : JSON.stringify(body),
|
|
542
|
+
};
|
|
543
|
+
return await handleResetUserPassword(req(`/api/users/${id}/reset-password`, init), id, deps());
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
test("401 with no Authorization header", async () => {
|
|
547
|
+
const res = await post(null, "some-id", { new_password: "twelvechars1" });
|
|
548
|
+
expect(res.status).toBe(401);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
552
|
+
const { bearer } = await makeAdminBearer(["other:scope"]);
|
|
553
|
+
const res = await post(bearer, "some-id", { new_password: "twelvechars1" });
|
|
554
|
+
expect(res.status).toBe(403);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("405 on GET", async () => {
|
|
558
|
+
const { bearer } = await makeAdminBearer();
|
|
559
|
+
const res = await handleResetUserPassword(
|
|
560
|
+
withBearer("/api/users/some-id/reset-password", bearer, { method: "GET" }),
|
|
561
|
+
"some-id",
|
|
562
|
+
deps(),
|
|
563
|
+
);
|
|
564
|
+
expect(res.status).toBe(405);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("404 when target user does not exist", async () => {
|
|
568
|
+
const { bearer } = await makeAdminBearer();
|
|
569
|
+
const res = await post(bearer, "no-such-id", { new_password: "twelvechars1" });
|
|
570
|
+
expect(res.status).toBe(404);
|
|
571
|
+
const body = (await res.json()) as { error: string };
|
|
572
|
+
expect(body.error).toBe("not_found");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("403 cannot_reset_first_admin when targeting the first admin", async () => {
|
|
576
|
+
const { bearer, userId } = await makeAdminBearer();
|
|
577
|
+
const res = await post(bearer, userId, { new_password: "twelvechars1" });
|
|
578
|
+
expect(res.status).toBe(403);
|
|
579
|
+
const body = (await res.json()) as { error: string };
|
|
580
|
+
expect(body.error).toBe("cannot_reset_first_admin");
|
|
581
|
+
// First-admin row's hash + password_changed bit untouched.
|
|
582
|
+
const fresh = getUserById(harness.db, userId);
|
|
583
|
+
expect(fresh?.passwordChanged).toBe(true);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("400 invalid_password when new_password is too short (< 12 chars)", async () => {
|
|
587
|
+
const { bearer } = await makeAdminBearer();
|
|
588
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
589
|
+
allowMulti: true,
|
|
590
|
+
passwordChanged: true,
|
|
591
|
+
});
|
|
592
|
+
const res = await post(bearer, friend.id, { new_password: "short" });
|
|
593
|
+
expect(res.status).toBe(400);
|
|
594
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
595
|
+
expect(body.error).toBe("invalid_password");
|
|
596
|
+
expect(body.error_description).toMatch(/12 characters/);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("413 password_too_long when new_password > 256 chars (before argon2id touches it)", async () => {
|
|
600
|
+
const { bearer } = await makeAdminBearer();
|
|
601
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
602
|
+
allowMulti: true,
|
|
603
|
+
passwordChanged: true,
|
|
604
|
+
});
|
|
605
|
+
const huge = "a".repeat(300);
|
|
606
|
+
const t0 = Date.now();
|
|
607
|
+
const res = await post(bearer, friend.id, { new_password: huge });
|
|
608
|
+
const elapsed = Date.now() - t0;
|
|
609
|
+
expect(res.status).toBe(413);
|
|
610
|
+
const body = (await res.json()) as { error: string };
|
|
611
|
+
expect(body.error).toBe("password_too_long");
|
|
612
|
+
// Same liveness check as the create-user path: 300-char argon2id is
|
|
613
|
+
// hundreds of ms; cap-and-reject should complete in <200ms.
|
|
614
|
+
expect(elapsed).toBeLessThan(200);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("400 invalid_request when content-type is not application/json", async () => {
|
|
618
|
+
const { bearer } = await makeAdminBearer();
|
|
619
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
620
|
+
allowMulti: true,
|
|
621
|
+
passwordChanged: true,
|
|
622
|
+
});
|
|
623
|
+
const res = await post(bearer, friend.id, "new_password=twelvechars1", {
|
|
624
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
625
|
+
});
|
|
626
|
+
expect(res.status).toBe(400);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("400 invalid_request when new_password missing", async () => {
|
|
630
|
+
const { bearer } = await makeAdminBearer();
|
|
631
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
632
|
+
allowMulti: true,
|
|
633
|
+
passwordChanged: true,
|
|
634
|
+
});
|
|
635
|
+
const res = await post(bearer, friend.id, {});
|
|
636
|
+
expect(res.status).toBe(400);
|
|
637
|
+
const body = (await res.json()) as { error: string };
|
|
638
|
+
expect(body.error).toBe("invalid_request");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("happy path — rotates hash, flips password_changed=false, returns user shape", async () => {
|
|
642
|
+
const { bearer } = await makeAdminBearer();
|
|
643
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
644
|
+
allowMulti: true,
|
|
645
|
+
passwordChanged: true,
|
|
646
|
+
});
|
|
647
|
+
const oldHash = friend.passwordHash;
|
|
648
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
649
|
+
expect(res.status).toBe(200);
|
|
650
|
+
const body = (await res.json()) as {
|
|
651
|
+
ok: boolean;
|
|
652
|
+
user: { id: string; password_changed: boolean; username: string };
|
|
653
|
+
};
|
|
654
|
+
expect(body.ok).toBe(true);
|
|
655
|
+
expect(body.user.id).toBe(friend.id);
|
|
656
|
+
expect(body.user.username).toBe("alice");
|
|
657
|
+
expect(body.user.password_changed).toBe(false);
|
|
658
|
+
// Echo body never carries the hash (or the new password).
|
|
659
|
+
expect(body.user).not.toHaveProperty("password_hash");
|
|
660
|
+
expect(body).not.toHaveProperty("new_password");
|
|
661
|
+
// Round-trip on the user row: new password works, old does not, hash
|
|
662
|
+
// moved, password_changed is now false (force-redirect on next login).
|
|
663
|
+
const fresh = getUserById(harness.db, friend.id);
|
|
664
|
+
expect(fresh).not.toBeNull();
|
|
665
|
+
expect(fresh?.passwordHash).not.toBe(oldHash);
|
|
666
|
+
expect(fresh?.passwordChanged).toBe(false);
|
|
667
|
+
expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
|
|
668
|
+
expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("response body includes revocation_lag_seconds = 60 so clients can surface the propagation lag (smoke 2026-05-27 finding 3)", async () => {
|
|
672
|
+
// Resource servers cache the revocation list via scope-guard's
|
|
673
|
+
// REVOCATION_CACHE_TTL_MS = 60_000 — so even though hub marks the
|
|
674
|
+
// user's tokens revoked immediately and publishes them on the
|
|
675
|
+
// revocation list, vault/scribe/etc. may keep accepting the
|
|
676
|
+
// revoked token for up to 60 seconds. For the stolen-device
|
|
677
|
+
// recovery threat model that's a meaningful exposure window
|
|
678
|
+
// the admin needs to know about. Surface the lag in the
|
|
679
|
+
// response body so the SPA's success banner can warn operators.
|
|
680
|
+
const { bearer } = await makeAdminBearer();
|
|
681
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
682
|
+
allowMulti: true,
|
|
683
|
+
passwordChanged: true,
|
|
684
|
+
});
|
|
685
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
686
|
+
expect(res.status).toBe(200);
|
|
687
|
+
const body = (await res.json()) as {
|
|
688
|
+
ok: boolean;
|
|
689
|
+
user: unknown;
|
|
690
|
+
revocation_lag_seconds: number;
|
|
691
|
+
};
|
|
692
|
+
expect(body.revocation_lag_seconds).toBe(60);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
|
|
696
|
+
const { bearer } = await makeAdminBearer();
|
|
697
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
698
|
+
allowMulti: true,
|
|
699
|
+
passwordChanged: true,
|
|
700
|
+
});
|
|
701
|
+
const minted = await signAccessToken(harness.db, {
|
|
702
|
+
sub: friend.id,
|
|
703
|
+
scopes: ["vault:home:read"],
|
|
704
|
+
audience: "vault",
|
|
705
|
+
clientId: "notes-client",
|
|
706
|
+
issuer: ISSUER,
|
|
707
|
+
ttlSeconds: 600,
|
|
708
|
+
});
|
|
709
|
+
recordTokenMint(harness.db, {
|
|
710
|
+
jti: minted.jti,
|
|
711
|
+
createdVia: "operator_mint",
|
|
712
|
+
subject: friend.username,
|
|
713
|
+
userId: friend.id,
|
|
714
|
+
clientId: "notes-client",
|
|
715
|
+
scopes: ["vault:home:read"],
|
|
716
|
+
expiresAt: minted.expiresAt,
|
|
717
|
+
});
|
|
718
|
+
expect(findTokenRowByJti(harness.db, minted.jti)?.revokedAt).toBeNull();
|
|
719
|
+
|
|
720
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
721
|
+
expect(res.status).toBe(200);
|
|
722
|
+
|
|
723
|
+
const row = findTokenRowByJti(harness.db, minted.jti);
|
|
724
|
+
expect(row?.revokedAt).not.toBeNull();
|
|
725
|
+
// User row sticks around (unlike delete), so user_id is NOT NULLed.
|
|
726
|
+
expect(row?.userId).toBe(friend.id);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
480
730
|
// ---------------------------------------------------------------------------
|
|
481
731
|
// GET /api/users/vaults — vault-name list for the assigned-vault dropdown
|
|
482
732
|
// ---------------------------------------------------------------------------
|
|
@@ -520,3 +770,130 @@ describe("handleListVaults", () => {
|
|
|
520
770
|
expect(body.vaults).toEqual(["home", "scratch", "work"]);
|
|
521
771
|
});
|
|
522
772
|
});
|
|
773
|
+
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
// PATCH /api/users/:id/vaults — edit vault assignments (Phase 2 PR 2)
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
describe("handleUpdateUserVaults", () => {
|
|
779
|
+
async function patch(
|
|
780
|
+
bearer: string,
|
|
781
|
+
userId: string,
|
|
782
|
+
body: Record<string, unknown> | string,
|
|
783
|
+
headers: Record<string, string> = {},
|
|
784
|
+
): Promise<Response> {
|
|
785
|
+
const init: RequestInit = {
|
|
786
|
+
method: "PATCH",
|
|
787
|
+
headers: {
|
|
788
|
+
"content-type": "application/json",
|
|
789
|
+
authorization: `Bearer ${bearer}`,
|
|
790
|
+
...headers,
|
|
791
|
+
},
|
|
792
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
793
|
+
};
|
|
794
|
+
return await handleUpdateUserVaults(req(`/api/users/${userId}/vaults`, init), userId, deps());
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
test("401 with no Authorization header", async () => {
|
|
798
|
+
const res = await handleUpdateUserVaults(
|
|
799
|
+
req("/api/users/some-id/vaults", {
|
|
800
|
+
method: "PATCH",
|
|
801
|
+
headers: { "content-type": "application/json" },
|
|
802
|
+
body: JSON.stringify({ assigned_vaults: [] }),
|
|
803
|
+
}),
|
|
804
|
+
"some-id",
|
|
805
|
+
deps(),
|
|
806
|
+
);
|
|
807
|
+
expect(res.status).toBe(401);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("405 on non-PATCH", async () => {
|
|
811
|
+
const { bearer } = await makeAdminBearer();
|
|
812
|
+
const res = await handleUpdateUserVaults(
|
|
813
|
+
withBearer("/api/users/x/vaults", bearer, { method: "POST" }),
|
|
814
|
+
"x",
|
|
815
|
+
deps(),
|
|
816
|
+
);
|
|
817
|
+
expect(res.status).toBe(405);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("404 when target user does not exist", async () => {
|
|
821
|
+
const { bearer } = await makeAdminBearer();
|
|
822
|
+
const res = await patch(bearer, "no-such-id", { assigned_vaults: [] });
|
|
823
|
+
expect(res.status).toBe(404);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("403 cannot_edit_first_admin_vaults for the first admin", async () => {
|
|
827
|
+
// The bearer-minting helper creates the first admin already; mint
|
|
828
|
+
// again returns the same admin's id.
|
|
829
|
+
const { bearer, userId } = await makeAdminBearer();
|
|
830
|
+
const res = await patch(bearer, userId, { assigned_vaults: ["home"] });
|
|
831
|
+
expect(res.status).toBe(403);
|
|
832
|
+
const body = (await res.json()) as { error: string };
|
|
833
|
+
expect(body.error).toBe("cannot_edit_first_admin_vaults");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("400 when assigned_vaults is missing", async () => {
|
|
837
|
+
const { bearer } = await makeAdminBearer();
|
|
838
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
839
|
+
allowMulti: true,
|
|
840
|
+
});
|
|
841
|
+
const res = await patch(bearer, friend.id, {});
|
|
842
|
+
expect(res.status).toBe(400);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test("400 when assigned_vaults entry is not a string", async () => {
|
|
846
|
+
const { bearer } = await makeAdminBearer();
|
|
847
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
848
|
+
allowMulti: true,
|
|
849
|
+
});
|
|
850
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: ["ok", 7] });
|
|
851
|
+
expect(res.status).toBe(400);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("400 assigned_vault_not_found when a vault doesn't exist in services.json", async () => {
|
|
855
|
+
harness.cleanup();
|
|
856
|
+
harness = makeHarness(manifestWithVaults("home"));
|
|
857
|
+
const { bearer } = await makeAdminBearer();
|
|
858
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
859
|
+
allowMulti: true,
|
|
860
|
+
});
|
|
861
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: ["home", "ghost"] });
|
|
862
|
+
expect(res.status).toBe(400);
|
|
863
|
+
const body = (await res.json()) as { error: string };
|
|
864
|
+
expect(body.error).toBe("assigned_vault_not_found");
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test("happy path — replaces the user's vault list and bumps updated_at", async () => {
|
|
868
|
+
harness.cleanup();
|
|
869
|
+
harness = makeHarness(manifestWithVaults("home", "work", "family"));
|
|
870
|
+
const { bearer } = await makeAdminBearer();
|
|
871
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
872
|
+
allowMulti: true,
|
|
873
|
+
assignedVaults: ["home"],
|
|
874
|
+
});
|
|
875
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: ["work", "family"] });
|
|
876
|
+
expect(res.status).toBe(200);
|
|
877
|
+
const body = (await res.json()) as {
|
|
878
|
+
ok: boolean;
|
|
879
|
+
user: { id: string; assigned_vaults: string[]; updated_at: string };
|
|
880
|
+
};
|
|
881
|
+
expect(body.ok).toBe(true);
|
|
882
|
+
expect(new Set(body.user.assigned_vaults)).toEqual(new Set(["work", "family"]));
|
|
883
|
+
expect(body.user.assigned_vaults).not.toContain("home");
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("empty array clears every assignment", async () => {
|
|
887
|
+
harness.cleanup();
|
|
888
|
+
harness = makeHarness(manifestWithVaults("home", "work"));
|
|
889
|
+
const { bearer } = await makeAdminBearer();
|
|
890
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
891
|
+
allowMulti: true,
|
|
892
|
+
assignedVaults: ["home", "work"],
|
|
893
|
+
});
|
|
894
|
+
const res = await patch(bearer, friend.id, { assigned_vaults: [] });
|
|
895
|
+
expect(res.status).toBe(200);
|
|
896
|
+
const fresh = getUserById(harness.db, friend.id);
|
|
897
|
+
expect(fresh?.assignedVaults).toEqual([]);
|
|
898
|
+
});
|
|
899
|
+
});
|
|
@@ -82,21 +82,21 @@ describe("shouldInjectChrome", () => {
|
|
|
82
82
|
expect(shouldInjectChrome("/admin/vaults")).toBe(true);
|
|
83
83
|
expect(shouldInjectChrome("/scribe/admin")).toBe(true);
|
|
84
84
|
expect(shouldInjectChrome("/vault/default/admin/")).toBe(true);
|
|
85
|
-
expect(shouldInjectChrome("/
|
|
85
|
+
expect(shouldInjectChrome("/surface/admin/modules")).toBe(true);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
test("default: opts out the Notes PWA at /
|
|
89
|
-
expect(shouldInjectChrome("/
|
|
90
|
-
expect(shouldInjectChrome("/
|
|
91
|
-
expect(shouldInjectChrome("/
|
|
92
|
-
expect(shouldInjectChrome("/
|
|
88
|
+
test("default: opts out the Notes PWA at /surface/notes/*", () => {
|
|
89
|
+
expect(shouldInjectChrome("/surface/notes")).toBe(false);
|
|
90
|
+
expect(shouldInjectChrome("/surface/notes/")).toBe(false);
|
|
91
|
+
expect(shouldInjectChrome("/surface/notes/index.html")).toBe(false);
|
|
92
|
+
expect(shouldInjectChrome("/surface/notes/assets/index-XXX.js")).toBe(false);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
test("opt-out prefix matching does not over-match sibling paths", () => {
|
|
96
|
-
// `/
|
|
96
|
+
// `/surface/notesbook` must NOT match `/surface/notes/` — startsWith check
|
|
97
97
|
// requires a trailing slash boundary.
|
|
98
|
-
expect(shouldInjectChrome("/
|
|
99
|
-
expect(shouldInjectChrome("/
|
|
98
|
+
expect(shouldInjectChrome("/surface/notesbook")).toBe(true);
|
|
99
|
+
expect(shouldInjectChrome("/surface/notes-archive/")).toBe(true);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
test("custom opt-out list is honored", () => {
|
|
@@ -111,8 +111,8 @@ describe("shouldInjectChrome", () => {
|
|
|
111
111
|
expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test("the canonical opt-out list contains /
|
|
115
|
-
expect(CHROME_OPT_OUT_PREFIXES).toContain("/
|
|
114
|
+
test("the canonical opt-out list contains /surface/notes/", () => {
|
|
115
|
+
expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/notes/");
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|
|
@@ -250,27 +250,27 @@ describe("injectChromeIntoResponse", () => {
|
|
|
250
250
|
expect(cssOut).toBe(css);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
-
test("passes through responses on opt-out paths (/
|
|
253
|
+
test("passes through responses on opt-out paths (/surface/notes/) unchanged", async () => {
|
|
254
254
|
const res = new Response("<html><body>notes</body></html>", {
|
|
255
255
|
status: 200,
|
|
256
256
|
headers: { "content-type": "text/html" },
|
|
257
257
|
});
|
|
258
258
|
const out = await injectChromeIntoResponse(res, {
|
|
259
259
|
chromeHtml: chrome,
|
|
260
|
-
pathname: "/
|
|
260
|
+
pathname: "/surface/notes/",
|
|
261
261
|
});
|
|
262
262
|
expect(out).toBe(res);
|
|
263
263
|
expect(await out.text()).toBe("<html><body>notes</body></html>");
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
test("passes through responses on opt-out sub-paths (/
|
|
266
|
+
test("passes through responses on opt-out sub-paths (/surface/notes/assets/x.js)", async () => {
|
|
267
267
|
const res = new Response("<html><body>notes</body></html>", {
|
|
268
268
|
status: 200,
|
|
269
269
|
headers: { "content-type": "text/html" },
|
|
270
270
|
});
|
|
271
271
|
const out = await injectChromeIntoResponse(res, {
|
|
272
272
|
chromeHtml: chrome,
|
|
273
|
-
pathname: "/
|
|
273
|
+
pathname: "/surface/notes/index.html",
|
|
274
274
|
});
|
|
275
275
|
expect(out).toBe(res);
|
|
276
276
|
});
|
|
@@ -217,11 +217,13 @@ describe("cli per-subcommand help", () => {
|
|
|
217
217
|
expect(stderr).toMatch(/parachute install vault/);
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
test("vault tokens create
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
220
|
+
test("vault tokens create forwards verbatim to parachute-vault", async () => {
|
|
221
|
+
// The guided interactive wrapper was removed with the pvt_* DROP (vault
|
|
222
|
+
// #412 / hub#466) — `vault tokens create` now always forwards transparently
|
|
223
|
+
// to parachute-vault (which exits 1 with migration guidance on a real box).
|
|
224
|
+
// Clearing PATH forces ENOENT — same probe as the `vault no-args` test.
|
|
225
|
+
// If we ever re-introduced a hub-side prompt, this subprocess would hang on
|
|
226
|
+
// stdin instead of exiting 127.
|
|
225
227
|
const proc = Bun.spawn([process.execPath, CLI, "vault", "tokens", "create"], {
|
|
226
228
|
stdout: "pipe",
|
|
227
229
|
stderr: "pipe",
|
|
@@ -59,10 +59,65 @@ describe("cloudflare detect", () => {
|
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
describe("cloudflaredInstallHint", () => {
|
|
63
|
+
test("darwin: names brew + points at GitHub releases as fallback", () => {
|
|
64
|
+
const hint = cloudflaredInstallHint("darwin", "arm64");
|
|
65
|
+
expect(hint).toContain("brew install cloudflared");
|
|
66
|
+
expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("linux x64: writes the curl line with the amd64 artifact suffix", () => {
|
|
70
|
+
// Refresh of stale URLs (2026-05-27). Aaron hit this on a fresh
|
|
71
|
+
// Amazon Linux 2023 install — `sudo dnf install cloudflared`
|
|
72
|
+
// returned 'No match for argument: cloudflared', and the hub's
|
|
73
|
+
// hint pointed at developers.cloudflare.com paths that 404. The
|
|
74
|
+
// GitHub release is the reliable cross-distro path.
|
|
75
|
+
const hint = cloudflaredInstallHint("linux", "x64");
|
|
76
|
+
expect(hint).toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
77
|
+
expect(hint).toContain(
|
|
78
|
+
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
|
79
|
+
);
|
|
80
|
+
expect(hint).toContain("sudo chmod +x /usr/local/bin/cloudflared");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("linux arm64: writes the arm64 artifact suffix", () => {
|
|
84
|
+
const hint = cloudflaredInstallHint("linux", "arm64");
|
|
85
|
+
expect(hint).toContain("cloudflared-linux-arm64");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("linux arm (32-bit): writes the arm artifact suffix", () => {
|
|
89
|
+
const hint = cloudflaredInstallHint("linux", "arm");
|
|
90
|
+
expect(hint).toContain("cloudflared-linux-arm");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("linux exotic arch: falls back to a generic GitHub releases pointer", () => {
|
|
94
|
+
// riscv64 / ppc64 / mips64 — no cloudflared artifact published, so
|
|
95
|
+
// we don't fabricate a 404-bound download URL; we point the user at
|
|
96
|
+
// the releases page and surface what their arch is so they can pick.
|
|
97
|
+
const hint = cloudflaredInstallHint("linux", "riscv64");
|
|
98
|
+
expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
|
|
99
|
+
expect(hint).toContain("riscv64");
|
|
100
|
+
expect(hint).not.toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("no stale developers.cloudflare.com or pkg.cloudflare.com paths anywhere", () => {
|
|
104
|
+
// Aaron caught both URL shapes returning HTML/404 on 2026-05-27 —
|
|
105
|
+
// they had been the hub's installer instructions for months.
|
|
106
|
+
// Hard-assert they're gone so they don't regress.
|
|
107
|
+
for (const platform of ["darwin", "linux"] as const) {
|
|
108
|
+
for (const arch of ["x64", "arm64"] as const) {
|
|
109
|
+
const hint = cloudflaredInstallHint(platform, arch);
|
|
110
|
+
expect(hint).not.toContain("developers.cloudflare.com");
|
|
111
|
+
expect(hint).not.toContain("pkg.cloudflare.com");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("non-Linux, non-darwin platform: GitHub releases pointer with no curl line", () => {
|
|
117
|
+
const hint = cloudflaredInstallHint("win32", "x64");
|
|
118
|
+
expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
|
|
119
|
+
expect(hint).not.toContain("brew install");
|
|
120
|
+
expect(hint).not.toContain("curl -L -o");
|
|
121
|
+
});
|
|
67
122
|
});
|
|
68
123
|
});
|