@openparachute/hub 0.5.13 → 0.5.14-rc.1
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +140 -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__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +192 -2
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +157 -19
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +261 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +404 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +173 -12
- package/src/chrome-strip.ts +6 -6
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-server.ts +50 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +335 -28
- package/src/users.ts +112 -0
- package/web/ui/dist/assets/index-Qf56GsGm.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -171,7 +171,7 @@ describe("GET /api/modules", () => {
|
|
|
171
171
|
supervisor_available: boolean;
|
|
172
172
|
};
|
|
173
173
|
// Curated order is preserved: vault → app → notes → scribe → runner.
|
|
174
|
-
expect(body.modules.map((m) => m.short)).toEqual(["vault", "
|
|
174
|
+
expect(body.modules.map((m) => m.short)).toEqual(["vault", "surface", "notes", "scribe", "runner"]);
|
|
175
175
|
expect(body.modules.every((m) => m.available)).toBe(true);
|
|
176
176
|
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
177
177
|
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
@@ -202,12 +202,12 @@ describe("GET /api/modules", () => {
|
|
|
202
202
|
available: boolean;
|
|
203
203
|
}>;
|
|
204
204
|
};
|
|
205
|
-
const
|
|
206
|
-
expect(
|
|
207
|
-
expect(
|
|
208
|
-
expect(
|
|
209
|
-
expect(
|
|
210
|
-
expect(
|
|
205
|
+
const surface = body.modules.find((m) => m.short === "surface");
|
|
206
|
+
expect(surface).toBeDefined();
|
|
207
|
+
expect(surface?.package).toBe("@openparachute/surface");
|
|
208
|
+
expect(surface?.display_name).toBe("Surface");
|
|
209
|
+
expect(surface?.tagline).toContain("auto-installs Notes");
|
|
210
|
+
expect(surface?.available).toBe(true);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
test("runner row carries package + display props from FIRST_PARTY_FALLBACKS (#305)", async () => {
|
|
@@ -366,9 +366,9 @@ describe("GET /api/modules", () => {
|
|
|
366
366
|
});
|
|
367
367
|
|
|
368
368
|
test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
|
|
369
|
-
// Audit caught 2026-05-25: app declares `managementUrl: "/
|
|
370
|
-
// (full hub-origin path) and `paths: ["/
|
|
371
|
-
// SPA's Services dropdown was navigating to `/app/
|
|
369
|
+
// Audit caught 2026-05-25: app declares `managementUrl: "/surface/admin/"`
|
|
370
|
+
// (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
|
|
371
|
+
// SPA's Services dropdown was navigating to `/app/surface/admin/` (404)
|
|
372
372
|
// because api-modules unconditionally prepended the mount onto the
|
|
373
373
|
// candidate. Fix: detect already-mount-prefixed paths and pass through.
|
|
374
374
|
//
|
|
@@ -376,12 +376,12 @@ describe("GET /api/modules", () => {
|
|
|
376
376
|
// multi-instance modules (vault) use the per-instance relative form.
|
|
377
377
|
writeManifest(h.manifestPath, [
|
|
378
378
|
{
|
|
379
|
-
name: "parachute-
|
|
379
|
+
name: "parachute-surface",
|
|
380
380
|
port: 1946,
|
|
381
|
-
paths: ["/
|
|
382
|
-
health: "/
|
|
381
|
+
paths: ["/surface", "/.parachute"],
|
|
382
|
+
health: "/surface/healthz",
|
|
383
383
|
version: "0.2.0-rc.13",
|
|
384
|
-
installDir: "/install/dir/
|
|
384
|
+
installDir: "/install/dir/surface",
|
|
385
385
|
},
|
|
386
386
|
]);
|
|
387
387
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
@@ -391,17 +391,17 @@ describe("GET /api/modules", () => {
|
|
|
391
391
|
manifestPath: h.manifestPath,
|
|
392
392
|
fetchLatestVersion: async () => null,
|
|
393
393
|
readModuleManifest: async (installDir) => {
|
|
394
|
-
if (installDir === "/install/dir/
|
|
394
|
+
if (installDir === "/install/dir/surface") {
|
|
395
395
|
return {
|
|
396
|
-
name: "
|
|
397
|
-
manifestName: "parachute-
|
|
398
|
-
displayName: "
|
|
396
|
+
name: "surface",
|
|
397
|
+
manifestName: "parachute-surface",
|
|
398
|
+
displayName: "Surface",
|
|
399
399
|
tagline: "",
|
|
400
400
|
port: 1946,
|
|
401
|
-
paths: ["/
|
|
402
|
-
health: "/
|
|
403
|
-
uiUrl: "/
|
|
404
|
-
managementUrl: "/
|
|
401
|
+
paths: ["/surface", "/.parachute"],
|
|
402
|
+
health: "/surface/healthz",
|
|
403
|
+
uiUrl: "/surface/admin/",
|
|
404
|
+
managementUrl: "/surface/admin/",
|
|
405
405
|
} as unknown as Awaited<
|
|
406
406
|
ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
|
|
407
407
|
>;
|
|
@@ -413,9 +413,9 @@ describe("GET /api/modules", () => {
|
|
|
413
413
|
const body = (await res.json()) as {
|
|
414
414
|
modules: Array<{ short: string; management_url: string | null }>;
|
|
415
415
|
};
|
|
416
|
-
const
|
|
417
|
-
// Single `/
|
|
418
|
-
expect(
|
|
416
|
+
const surface = body.modules.find((m) => m.short === "surface");
|
|
417
|
+
// Single `/surface/`, not `/surface/surface/`.
|
|
418
|
+
expect(surface?.management_url).toBe("/surface/admin/");
|
|
419
419
|
});
|
|
420
420
|
|
|
421
421
|
test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
|
|
@@ -430,8 +430,8 @@ describe("GET /api/modules", () => {
|
|
|
430
430
|
{
|
|
431
431
|
name: "parachute-vault",
|
|
432
432
|
port: 1940,
|
|
433
|
-
paths: ["/
|
|
434
|
-
health: "/
|
|
433
|
+
paths: ["/surface"], // mount is /app (using vault as a stand-in installable)
|
|
434
|
+
health: "/surface/health",
|
|
435
435
|
version: "0.4.5",
|
|
436
436
|
installDir: "/install/dir/contrived",
|
|
437
437
|
},
|
|
@@ -450,8 +450,8 @@ describe("GET /api/modules", () => {
|
|
|
450
450
|
displayName: "Vault",
|
|
451
451
|
tagline: "",
|
|
452
452
|
port: 1940,
|
|
453
|
-
paths: ["/
|
|
454
|
-
health: "/
|
|
453
|
+
paths: ["/surface"],
|
|
454
|
+
health: "/surface/health",
|
|
455
455
|
// candidate looks like a sibling-name prefix but is NOT a
|
|
456
456
|
// mount-prefix of /app — should still get prepended.
|
|
457
457
|
managementUrl: "/app-foo/admin",
|
|
@@ -467,9 +467,9 @@ describe("GET /api/modules", () => {
|
|
|
467
467
|
modules: Array<{ short: string; management_url: string | null }>;
|
|
468
468
|
};
|
|
469
469
|
const vault = body.modules.find((m) => m.short === "vault");
|
|
470
|
-
// /
|
|
471
|
-
// as already-mount-prefixed because /app-foo/ doesn't start with /
|
|
472
|
-
expect(vault?.management_url).toBe("/
|
|
470
|
+
// /surface + /app-foo/admin → /surface/app-foo/admin (prepend fires; not
|
|
471
|
+
// treated as already-mount-prefixed because /app-foo/ doesn't start with /surface/).
|
|
472
|
+
expect(vault?.management_url).toBe("/surface/app-foo/admin");
|
|
473
473
|
});
|
|
474
474
|
|
|
475
475
|
test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {
|
|
@@ -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,11 @@ import {
|
|
|
25
30
|
handleDeleteUser,
|
|
26
31
|
handleListUsers,
|
|
27
32
|
handleListVaults,
|
|
33
|
+
handleResetUserPassword,
|
|
28
34
|
} from "../api-users.ts";
|
|
29
35
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
30
36
|
import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
31
|
-
import { createUser } from "../users.ts";
|
|
37
|
+
import { createUser, getUserById, verifyPassword } from "../users.ts";
|
|
32
38
|
|
|
33
39
|
const ISSUER = "https://hub.test";
|
|
34
40
|
const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
@@ -477,6 +483,190 @@ describe("handleDeleteUser", () => {
|
|
|
477
483
|
});
|
|
478
484
|
});
|
|
479
485
|
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// POST /api/users/:id/reset-password — admin-initiated password reset
|
|
488
|
+
// (multi-user Phase 2 PR 1)
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
describe("handleResetUserPassword", () => {
|
|
492
|
+
async function post(
|
|
493
|
+
bearer: string | null,
|
|
494
|
+
id: string,
|
|
495
|
+
body: Record<string, unknown> | string | null,
|
|
496
|
+
headers: Record<string, string> = {},
|
|
497
|
+
): Promise<Response> {
|
|
498
|
+
const init: RequestInit = {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: {
|
|
501
|
+
"content-type": "application/json",
|
|
502
|
+
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
|
|
503
|
+
...headers,
|
|
504
|
+
},
|
|
505
|
+
body: body === null ? undefined : typeof body === "string" ? body : JSON.stringify(body),
|
|
506
|
+
};
|
|
507
|
+
return await handleResetUserPassword(req(`/api/users/${id}/reset-password`, init), id, deps());
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
test("401 with no Authorization header", async () => {
|
|
511
|
+
const res = await post(null, "some-id", { new_password: "twelvechars1" });
|
|
512
|
+
expect(res.status).toBe(401);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
516
|
+
const { bearer } = await makeAdminBearer(["other:scope"]);
|
|
517
|
+
const res = await post(bearer, "some-id", { new_password: "twelvechars1" });
|
|
518
|
+
expect(res.status).toBe(403);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("405 on GET", async () => {
|
|
522
|
+
const { bearer } = await makeAdminBearer();
|
|
523
|
+
const res = await handleResetUserPassword(
|
|
524
|
+
withBearer("/api/users/some-id/reset-password", bearer, { method: "GET" }),
|
|
525
|
+
"some-id",
|
|
526
|
+
deps(),
|
|
527
|
+
);
|
|
528
|
+
expect(res.status).toBe(405);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("404 when target user does not exist", async () => {
|
|
532
|
+
const { bearer } = await makeAdminBearer();
|
|
533
|
+
const res = await post(bearer, "no-such-id", { new_password: "twelvechars1" });
|
|
534
|
+
expect(res.status).toBe(404);
|
|
535
|
+
const body = (await res.json()) as { error: string };
|
|
536
|
+
expect(body.error).toBe("not_found");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("403 cannot_reset_first_admin when targeting the first admin", async () => {
|
|
540
|
+
const { bearer, userId } = await makeAdminBearer();
|
|
541
|
+
const res = await post(bearer, userId, { new_password: "twelvechars1" });
|
|
542
|
+
expect(res.status).toBe(403);
|
|
543
|
+
const body = (await res.json()) as { error: string };
|
|
544
|
+
expect(body.error).toBe("cannot_reset_first_admin");
|
|
545
|
+
// First-admin row's hash + password_changed bit untouched.
|
|
546
|
+
const fresh = getUserById(harness.db, userId);
|
|
547
|
+
expect(fresh?.passwordChanged).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("400 invalid_password when new_password is too short (< 12 chars)", async () => {
|
|
551
|
+
const { bearer } = await makeAdminBearer();
|
|
552
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
553
|
+
allowMulti: true,
|
|
554
|
+
passwordChanged: true,
|
|
555
|
+
});
|
|
556
|
+
const res = await post(bearer, friend.id, { new_password: "short" });
|
|
557
|
+
expect(res.status).toBe(400);
|
|
558
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
559
|
+
expect(body.error).toBe("invalid_password");
|
|
560
|
+
expect(body.error_description).toMatch(/12 characters/);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("413 password_too_long when new_password > 256 chars (before argon2id touches it)", async () => {
|
|
564
|
+
const { bearer } = await makeAdminBearer();
|
|
565
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
566
|
+
allowMulti: true,
|
|
567
|
+
passwordChanged: true,
|
|
568
|
+
});
|
|
569
|
+
const huge = "a".repeat(300);
|
|
570
|
+
const t0 = Date.now();
|
|
571
|
+
const res = await post(bearer, friend.id, { new_password: huge });
|
|
572
|
+
const elapsed = Date.now() - t0;
|
|
573
|
+
expect(res.status).toBe(413);
|
|
574
|
+
const body = (await res.json()) as { error: string };
|
|
575
|
+
expect(body.error).toBe("password_too_long");
|
|
576
|
+
// Same liveness check as the create-user path: 300-char argon2id is
|
|
577
|
+
// hundreds of ms; cap-and-reject should complete in <200ms.
|
|
578
|
+
expect(elapsed).toBeLessThan(200);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("400 invalid_request when content-type is not application/json", async () => {
|
|
582
|
+
const { bearer } = await makeAdminBearer();
|
|
583
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
584
|
+
allowMulti: true,
|
|
585
|
+
passwordChanged: true,
|
|
586
|
+
});
|
|
587
|
+
const res = await post(bearer, friend.id, "new_password=twelvechars1", {
|
|
588
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
589
|
+
});
|
|
590
|
+
expect(res.status).toBe(400);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("400 invalid_request when new_password missing", async () => {
|
|
594
|
+
const { bearer } = await makeAdminBearer();
|
|
595
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
596
|
+
allowMulti: true,
|
|
597
|
+
passwordChanged: true,
|
|
598
|
+
});
|
|
599
|
+
const res = await post(bearer, friend.id, {});
|
|
600
|
+
expect(res.status).toBe(400);
|
|
601
|
+
const body = (await res.json()) as { error: string };
|
|
602
|
+
expect(body.error).toBe("invalid_request");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("happy path — rotates hash, flips password_changed=false, returns user shape", async () => {
|
|
606
|
+
const { bearer } = await makeAdminBearer();
|
|
607
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
608
|
+
allowMulti: true,
|
|
609
|
+
passwordChanged: true,
|
|
610
|
+
});
|
|
611
|
+
const oldHash = friend.passwordHash;
|
|
612
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
613
|
+
expect(res.status).toBe(200);
|
|
614
|
+
const body = (await res.json()) as {
|
|
615
|
+
ok: boolean;
|
|
616
|
+
user: { id: string; password_changed: boolean; username: string };
|
|
617
|
+
};
|
|
618
|
+
expect(body.ok).toBe(true);
|
|
619
|
+
expect(body.user.id).toBe(friend.id);
|
|
620
|
+
expect(body.user.username).toBe("alice");
|
|
621
|
+
expect(body.user.password_changed).toBe(false);
|
|
622
|
+
// Echo body never carries the hash (or the new password).
|
|
623
|
+
expect(body.user).not.toHaveProperty("password_hash");
|
|
624
|
+
expect(body).not.toHaveProperty("new_password");
|
|
625
|
+
// Round-trip on the user row: new password works, old does not, hash
|
|
626
|
+
// moved, password_changed is now false (force-redirect on next login).
|
|
627
|
+
const fresh = getUserById(harness.db, friend.id);
|
|
628
|
+
expect(fresh).not.toBeNull();
|
|
629
|
+
expect(fresh?.passwordHash).not.toBe(oldHash);
|
|
630
|
+
expect(fresh?.passwordChanged).toBe(false);
|
|
631
|
+
expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
|
|
632
|
+
expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
|
|
636
|
+
const { bearer } = await makeAdminBearer();
|
|
637
|
+
const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
638
|
+
allowMulti: true,
|
|
639
|
+
passwordChanged: true,
|
|
640
|
+
});
|
|
641
|
+
const minted = await signAccessToken(harness.db, {
|
|
642
|
+
sub: friend.id,
|
|
643
|
+
scopes: ["vault:home:read"],
|
|
644
|
+
audience: "vault",
|
|
645
|
+
clientId: "notes-client",
|
|
646
|
+
issuer: ISSUER,
|
|
647
|
+
ttlSeconds: 600,
|
|
648
|
+
});
|
|
649
|
+
recordTokenMint(harness.db, {
|
|
650
|
+
jti: minted.jti,
|
|
651
|
+
createdVia: "operator_mint",
|
|
652
|
+
subject: friend.username,
|
|
653
|
+
userId: friend.id,
|
|
654
|
+
clientId: "notes-client",
|
|
655
|
+
scopes: ["vault:home:read"],
|
|
656
|
+
expiresAt: minted.expiresAt,
|
|
657
|
+
});
|
|
658
|
+
expect(findTokenRowByJti(harness.db, minted.jti)?.revokedAt).toBeNull();
|
|
659
|
+
|
|
660
|
+
const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
|
|
661
|
+
expect(res.status).toBe(200);
|
|
662
|
+
|
|
663
|
+
const row = findTokenRowByJti(harness.db, minted.jti);
|
|
664
|
+
expect(row?.revokedAt).not.toBeNull();
|
|
665
|
+
// User row sticks around (unlike delete), so user_id is NOT NULLed.
|
|
666
|
+
expect(row?.userId).toBe(friend.id);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
480
670
|
// ---------------------------------------------------------------------------
|
|
481
671
|
// GET /api/users/vaults — vault-name list for the assigned-vault dropdown
|
|
482
672
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
});
|
|
@@ -995,22 +995,22 @@ describe("hubFetch routing", () => {
|
|
|
995
995
|
});
|
|
996
996
|
|
|
997
997
|
// Notes-as-app migration Phase 2 (parachute-app design doc §16).
|
|
998
|
-
// `/notes/*` 301-redirects to `/
|
|
998
|
+
// `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land
|
|
999
999
|
// on the apps-hosted Notes. Tested with no DB (the migration-default
|
|
1000
1000
|
// path — absent DB or absent row means redirect-on).
|
|
1001
|
-
test("301: /notes/ → /
|
|
1001
|
+
test("301: /notes/ → /surface/notes/", async () => {
|
|
1002
1002
|
clearNotesRedirectLogState();
|
|
1003
1003
|
const h = makeHarness();
|
|
1004
1004
|
try {
|
|
1005
1005
|
const res = await hubFetch(h.dir)(req("/notes/"));
|
|
1006
1006
|
expect(res.status).toBe(301);
|
|
1007
|
-
expect(res.headers.get("location")).toBe("/
|
|
1007
|
+
expect(res.headers.get("location")).toBe("/surface/notes/");
|
|
1008
1008
|
} finally {
|
|
1009
1009
|
h.cleanup();
|
|
1010
1010
|
}
|
|
1011
1011
|
});
|
|
1012
1012
|
|
|
1013
|
-
test("301: bare /notes → /
|
|
1013
|
+
test("301: bare /notes → /surface/notes", async () => {
|
|
1014
1014
|
// The bare-prefix form (no trailing slash) is the path browsers land
|
|
1015
1015
|
// on when an operator types `https://hub.example/notes` directly.
|
|
1016
1016
|
clearNotesRedirectLogState();
|
|
@@ -1018,7 +1018,7 @@ describe("hubFetch routing", () => {
|
|
|
1018
1018
|
try {
|
|
1019
1019
|
const res = await hubFetch(h.dir)(req("/notes"));
|
|
1020
1020
|
expect(res.status).toBe(301);
|
|
1021
|
-
expect(res.headers.get("location")).toBe("/
|
|
1021
|
+
expect(res.headers.get("location")).toBe("/surface/notes");
|
|
1022
1022
|
} finally {
|
|
1023
1023
|
h.cleanup();
|
|
1024
1024
|
}
|
|
@@ -1030,7 +1030,7 @@ describe("hubFetch routing", () => {
|
|
|
1030
1030
|
try {
|
|
1031
1031
|
const res = await hubFetch(h.dir)(req("/notes/some/path?q=1&n=2"));
|
|
1032
1032
|
expect(res.status).toBe(301);
|
|
1033
|
-
expect(res.headers.get("location")).toBe("/
|
|
1033
|
+
expect(res.headers.get("location")).toBe("/surface/notes/some/path?q=1&n=2");
|
|
1034
1034
|
} finally {
|
|
1035
1035
|
h.cleanup();
|
|
1036
1036
|
}
|
|
@@ -2240,7 +2240,7 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
2240
2240
|
// motivator for the `--mount` strip in notes-serve.ts).
|
|
2241
2241
|
//
|
|
2242
2242
|
// Post-parachute-app §16 Phase 2 the `/notes/*` path 301-redirects to
|
|
2243
|
-
// `/
|
|
2243
|
+
// `/surface/notes/*` by default. This test pins the notes-as-module legacy
|
|
2244
2244
|
// path (notes-daemon still serving its own mount); set the opt-out
|
|
2245
2245
|
// flag so the dispatch falls through to the generic proxy.
|
|
2246
2246
|
const h = makeHarness();
|
|
@@ -3453,7 +3453,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3453
3453
|
// Pins the proxy-side wiring of the chrome strip from
|
|
3454
3454
|
// `parachute-patterns/patterns/design-system.md` §7 — every proxied
|
|
3455
3455
|
// text/html response gets the strip injected after the first `<body>`,
|
|
3456
|
-
// with opt-outs for `/
|
|
3456
|
+
// with opt-outs for `/surface/notes/*` (the Notes PWA owns its own chrome).
|
|
3457
3457
|
// The pure rewrite + opt-out logic is covered in chrome-strip.test.ts;
|
|
3458
3458
|
// here we exercise the dispatch integration end-to-end through hubFetch.
|
|
3459
3459
|
|
|
@@ -3608,7 +3608,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3608
3608
|
}
|
|
3609
3609
|
});
|
|
3610
3610
|
|
|
3611
|
-
test("does NOT inject chrome on /
|
|
3611
|
+
test("does NOT inject chrome on /surface/notes/* (Notes PWA owns its own chrome)", async () => {
|
|
3612
3612
|
const h = makeHarness();
|
|
3613
3613
|
const upstream = startHtmlUpstream("<html><body><h1>Notes</h1></body></html>");
|
|
3614
3614
|
try {
|
|
@@ -3616,10 +3616,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3616
3616
|
{
|
|
3617
3617
|
services: [
|
|
3618
3618
|
{
|
|
3619
|
-
name: "parachute-
|
|
3619
|
+
name: "parachute-surface",
|
|
3620
3620
|
port: upstream.port,
|
|
3621
|
-
paths: ["/
|
|
3622
|
-
health: "/
|
|
3621
|
+
paths: ["/surface"],
|
|
3622
|
+
health: "/surface/health",
|
|
3623
3623
|
version: "0.1.0",
|
|
3624
3624
|
},
|
|
3625
3625
|
],
|
|
@@ -3627,7 +3627,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3627
3627
|
h.manifestPath,
|
|
3628
3628
|
);
|
|
3629
3629
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3630
|
-
const res = await fetcher(req("/
|
|
3630
|
+
const res = await fetcher(req("/surface/notes/"));
|
|
3631
3631
|
expect(res.status).toBe(200);
|
|
3632
3632
|
const body = await res.text();
|
|
3633
3633
|
expect(body).toBe("<html><body><h1>Notes</h1></body></html>");
|
|
@@ -3638,7 +3638,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3638
3638
|
}
|
|
3639
3639
|
});
|
|
3640
3640
|
|
|
3641
|
-
test("DOES inject chrome on /
|
|
3641
|
+
test("DOES inject chrome on /surface/admin/* (parachute-app admin, not Notes)", async () => {
|
|
3642
3642
|
const h = makeHarness();
|
|
3643
3643
|
const upstream = startHtmlUpstream("<html><body>app admin</body></html>");
|
|
3644
3644
|
try {
|
|
@@ -3646,10 +3646,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3646
3646
|
{
|
|
3647
3647
|
services: [
|
|
3648
3648
|
{
|
|
3649
|
-
name: "parachute-
|
|
3649
|
+
name: "parachute-surface",
|
|
3650
3650
|
port: upstream.port,
|
|
3651
|
-
paths: ["/
|
|
3652
|
-
health: "/
|
|
3651
|
+
paths: ["/surface"],
|
|
3652
|
+
health: "/surface/health",
|
|
3653
3653
|
version: "0.1.0",
|
|
3654
3654
|
},
|
|
3655
3655
|
],
|
|
@@ -3657,7 +3657,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3657
3657
|
h.manifestPath,
|
|
3658
3658
|
);
|
|
3659
3659
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3660
|
-
const res = await fetcher(req("/
|
|
3660
|
+
const res = await fetcher(req("/surface/admin/"));
|
|
3661
3661
|
expect(res.status).toBe(200);
|
|
3662
3662
|
const body = await res.text();
|
|
3663
3663
|
expect(body).toContain("pc-chrome");
|
|
@@ -3668,7 +3668,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3668
3668
|
}
|
|
3669
3669
|
});
|
|
3670
3670
|
|
|
3671
|
-
test("does NOT inject on /
|
|
3671
|
+
test("does NOT inject on /surface/notes/ sub-paths (asset requests)", async () => {
|
|
3672
3672
|
const h = makeHarness();
|
|
3673
3673
|
const upstream = startHtmlUpstream("<html><body>asset shell</body></html>");
|
|
3674
3674
|
try {
|
|
@@ -3676,10 +3676,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3676
3676
|
{
|
|
3677
3677
|
services: [
|
|
3678
3678
|
{
|
|
3679
|
-
name: "parachute-
|
|
3679
|
+
name: "parachute-surface",
|
|
3680
3680
|
port: upstream.port,
|
|
3681
|
-
paths: ["/
|
|
3682
|
-
health: "/
|
|
3681
|
+
paths: ["/surface"],
|
|
3682
|
+
health: "/surface/health",
|
|
3683
3683
|
version: "0.1.0",
|
|
3684
3684
|
},
|
|
3685
3685
|
],
|
|
@@ -3687,7 +3687,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
|
|
|
3687
3687
|
h.manifestPath,
|
|
3688
3688
|
);
|
|
3689
3689
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3690
|
-
const res = await fetcher(req("/
|
|
3690
|
+
const res = await fetcher(req("/surface/notes/index.html"));
|
|
3691
3691
|
expect(res.status).toBe(200);
|
|
3692
3692
|
const body = await res.text();
|
|
3693
3693
|
expect(body).not.toContain("pc-chrome");
|