@openparachute/hub 0.7.6 → 0.7.7-rc.12
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 +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +12 -0
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-server.test.ts +37 -0
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +279 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +2 -1
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/install.ts +86 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +247 -52
- package/src/hub-settings.ts +25 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +128 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
package/src/services-manifest.ts
CHANGED
|
@@ -623,7 +623,8 @@ export function readManifestLenient(
|
|
|
623
623
|
);
|
|
624
624
|
return { services: [] };
|
|
625
625
|
}
|
|
626
|
-
const
|
|
626
|
+
const healed = healStaleUiHostAppRows(raw, path);
|
|
627
|
+
const afterRetired = dropRetiredModuleRows(healed.raw, path);
|
|
627
628
|
const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
|
|
628
629
|
// `typeof null === "object"` in JS, so the `!cleaned.raw` part of this
|
|
629
630
|
// guard is load-bearing for the null case — not a typo or redundancy.
|
|
@@ -671,23 +672,29 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
|
|
|
671
672
|
`failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
672
673
|
);
|
|
673
674
|
}
|
|
674
|
-
//
|
|
675
|
+
// Row-heal + retired-module + legacy short-name cleanup all run BEFORE shape
|
|
675
676
|
// validation because the bugs they heal (rows that conflict on port with
|
|
676
677
|
// a current row) would otherwise throw inside `validateManifest`'s
|
|
677
|
-
// duplicate-port gate. Order matters:
|
|
678
|
-
//
|
|
679
|
-
// `
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
// the
|
|
683
|
-
//
|
|
684
|
-
|
|
678
|
+
// duplicate-port gate. Order matters:
|
|
679
|
+
// 1. `healStaleUiHostAppRows` FIRST — a pre-rename operator can carry BOTH
|
|
680
|
+
// a `parachute-app` AND an `app` stale row (2026-05-22 double-write
|
|
681
|
+
// reproducer); dropping by OLD-UI-host shape here clears both, whereas
|
|
682
|
+
// `dropLegacyShortNameRows` would keep the `parachute-app` half. Runs
|
|
683
|
+
// ahead of the retired-module pass too (the `app`/`parachute-app`
|
|
684
|
+
// RETIRED_MODULES entries were removed in hub-parity P5 — the names are
|
|
685
|
+
// the NEW app's now — so this shape-gated heal is their replacement).
|
|
686
|
+
// 2. `dropRetiredModuleRows` — drop genuinely-retired rows so their
|
|
687
|
+
// absence can unmask a `parachute-<X>` ↔ `<X>` pair underneath that
|
|
688
|
+
// 3. `dropLegacyShortNameRows` then handles.
|
|
689
|
+
// Each pass mutates the raw JSON object's `services` array; we re-validate
|
|
690
|
+
// against the cleaned shape. See the three helpers for the full discipline.
|
|
691
|
+
const healed = healStaleUiHostAppRows(raw, path);
|
|
692
|
+
const afterRetired = dropRetiredModuleRows(healed.raw, path);
|
|
685
693
|
const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
|
|
686
694
|
const validated = validateManifest(cleaned.raw, path);
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
return migrated.manifest;
|
|
695
|
+
const changed = healed.changed || afterRetired.changed || cleaned.changed;
|
|
696
|
+
if (changed) writeManifest(validated, path);
|
|
697
|
+
return validated;
|
|
691
698
|
}
|
|
692
699
|
|
|
693
700
|
/**
|
|
@@ -724,7 +731,12 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
|
|
|
724
731
|
if (!row || typeof row !== "object") return true;
|
|
725
732
|
const name = (row as Record<string, unknown>).name;
|
|
726
733
|
if (typeof name !== "string") return true;
|
|
727
|
-
|
|
734
|
+
// `claw` is too generic to reserve globally, but the historical Agent row
|
|
735
|
+
// had a structural identity: name=claw mounted at /claw. Retire only that
|
|
736
|
+
// shape; an unrelated third-party `claw` on another mount remains valid.
|
|
737
|
+
const paths = (row as Record<string, unknown>).paths;
|
|
738
|
+
const retiredClawShape = name === "claw" && Array.isArray(paths) && paths[0] === "/claw";
|
|
739
|
+
const retired = RETIRED_MODULES[name] ?? (retiredClawShape ? RETIRED_MODULES.agent : undefined);
|
|
728
740
|
if (retired === undefined) return true;
|
|
729
741
|
dropped.push({ name, retiredAt: retired.retiredAt, replacement: retired.replacement });
|
|
730
742
|
return false;
|
|
@@ -745,6 +757,91 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
|
|
|
745
757
|
};
|
|
746
758
|
}
|
|
747
759
|
|
|
760
|
+
/**
|
|
761
|
+
* Heal stale OLD-UI-host `app`/`parachute-app` rows on load (hub-parity P5,
|
|
762
|
+
* 2026-07-11).
|
|
763
|
+
*
|
|
764
|
+
* Context: the name `parachute-app` originally belonged to the UI-host module
|
|
765
|
+
* that renamed to `parachute-surface` on 2026-05-27 (patterns#102). Its
|
|
766
|
+
* retirement used to be handled by a `RETIRED_MODULES` entry that
|
|
767
|
+
* `dropRetiredModuleRows` GC'd unconditionally. Hub-parity P5 REUSES the
|
|
768
|
+
* `parachute-app` manifestName (+ the bare `app` short) for a genuinely NEW,
|
|
769
|
+
* unrelated module — the super-surface front door — so that retirement entry
|
|
770
|
+
* had to be removed (keeping it would GC the new module's own row on every
|
|
771
|
+
* read). This heal is the surgically narrow replacement for the ONE case the
|
|
772
|
+
* removed retirement still needs to cover: an operator upgrading DIRECTLY from
|
|
773
|
+
* a pre-rename release (~0.5.13-stable) who skipped every intermediate boot
|
|
774
|
+
* that would have GC'd their stale row. Without a heal, that stale row would:
|
|
775
|
+
* - crash-loop on boot — `reconcilePortToCanonical` rewrites its port to
|
|
776
|
+
* 1944, then the supervisor spawns the app static-serve shim against a
|
|
777
|
+
* `@openparachute/parachute-app` that isn't installed → exit 1 → a
|
|
778
|
+
* forever-restarting "app" unit the operator never installed;
|
|
779
|
+
* - hijack `/surface/*` if a real `parachute-surface` row is also present
|
|
780
|
+
* (both claim `/surface`);
|
|
781
|
+
* - and, after `parachute install app`, keep its stale `/surface` mount so
|
|
782
|
+
* the app serves under `/surface` while the install's `root_redirect=/app/`
|
|
783
|
+
* points the front page at a 404.
|
|
784
|
+
*
|
|
785
|
+
* Discriminant (structural — pinned against the OLD-UI-host row shape verified
|
|
786
|
+
* in `git show origin/main`'s retired-`app` + legacy-short-name test fixtures:
|
|
787
|
+
* `port: 1946`, `paths: ["/surface"]`, `health: "/surface/..."`):
|
|
788
|
+
* - `name` is exactly `"app"` or `"parachute-app"`, AND
|
|
789
|
+
* - it is NOT the NEW app's own row (the new app mounts at `/app` — never
|
|
790
|
+
* healed regardless of port), AND
|
|
791
|
+
* - it carries the OLD-UI-host signature: `paths[0]` is `/surface`-rooted OR
|
|
792
|
+
* `port === 1946` (the old canonical slot; now `parachute-surface`'s).
|
|
793
|
+
*
|
|
794
|
+
* The NEW app row (`paths: ["/app"]`, port 1944) is structurally disjoint on
|
|
795
|
+
* BOTH clauses, so it is always left intact. Matches `dropRetiredModuleRows`'s
|
|
796
|
+
* behavior: a plain DROP + a stderr warning steering the operator to
|
|
797
|
+
* `parachute install surface` (the same replacement the retirement named).
|
|
798
|
+
*
|
|
799
|
+
* Operates on raw JSON (before `validateManifest`) because a stale row can
|
|
800
|
+
* collide on port 1946 with a real `parachute-surface` row and would otherwise
|
|
801
|
+
* trip the duplicate-port gate before this heal runs. Runs FIRST in the load
|
|
802
|
+
* pipeline (ahead of `dropRetiredModuleRows` / `dropLegacyShortNameRows`): a
|
|
803
|
+
* pre-rename operator can carry BOTH a `parachute-app` and an `app` stale row
|
|
804
|
+
* (the 2026-05-22 double-write reproducer) — dropping by shape here clears
|
|
805
|
+
* both, whereas `dropLegacyShortNameRows` would keep the `parachute-app` half.
|
|
806
|
+
*/
|
|
807
|
+
function healStaleUiHostAppRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
|
|
808
|
+
if (!raw || typeof raw !== "object") return { raw, changed: false };
|
|
809
|
+
const services = (raw as Record<string, unknown>).services;
|
|
810
|
+
if (!Array.isArray(services)) return { raw, changed: false };
|
|
811
|
+
|
|
812
|
+
const dropped: string[] = [];
|
|
813
|
+
const nextServices = services.filter((row) => {
|
|
814
|
+
if (!row || typeof row !== "object") return true;
|
|
815
|
+
const rec = row as Record<string, unknown>;
|
|
816
|
+
const name = rec.name;
|
|
817
|
+
if (name !== "app" && name !== "parachute-app") return true;
|
|
818
|
+
const paths = rec.paths;
|
|
819
|
+
const first = Array.isArray(paths) && typeof paths[0] === "string" ? paths[0] : undefined;
|
|
820
|
+
// NEW super-surface app mounts at `/app` (port 1944) — never heal it,
|
|
821
|
+
// whatever its port happens to be after a reconcile/collision walk.
|
|
822
|
+
if (first === "/app" || first?.startsWith("/app/")) return true;
|
|
823
|
+
const port = rec.port;
|
|
824
|
+
const oldSurfaceMount = first === "/surface" || first?.startsWith("/surface/") === true;
|
|
825
|
+
const oldCanonicalPort = port === 1946;
|
|
826
|
+
if (!oldSurfaceMount && !oldCanonicalPort) return true;
|
|
827
|
+
dropped.push(String(name));
|
|
828
|
+
return false;
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
if (dropped.length === 0) return { raw, changed: false };
|
|
832
|
+
|
|
833
|
+
for (const name of dropped) {
|
|
834
|
+
console.error(
|
|
835
|
+
`${where}: dropped stale pre-rename '${name}' row (the OLD UI-host module, renamed to parachute-surface 2026-05-27). The name now belongs to the new Parachute app (mount /app, port 1944). If an old daemon is still running, stop it; install the current UI host with \`parachute install surface\`.`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
raw: { ...(raw as Record<string, unknown>), services: nextServices },
|
|
841
|
+
changed: true,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
748
845
|
/**
|
|
749
846
|
* Drop legacy short-name rows when a same-module manifestName row exists
|
|
750
847
|
* on the same port. Handles the duplicate-row class introduced when a
|
|
@@ -836,43 +933,6 @@ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; c
|
|
|
836
933
|
};
|
|
837
934
|
}
|
|
838
935
|
|
|
839
|
-
/**
|
|
840
|
-
* Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
|
|
841
|
-
* to parachute-agent across the ecosystem (npm package, mount path, short
|
|
842
|
-
* name); operators who upgraded hub but still have the old paraclaw row
|
|
843
|
-
* in services.json would otherwise see a tile labelled "Claw" and a hub
|
|
844
|
-
* route at `/claw` while their newly-upgraded daemon listens on `/agent`.
|
|
845
|
-
*
|
|
846
|
-
* Idempotent. Only rewrites when both `name === "claw"` AND the first path
|
|
847
|
-
* is `/claw` — narrow enough that a deliberately-named third-party module
|
|
848
|
-
* (e.g. `name: "claw"` on a different mount) is left alone. Health and any
|
|
849
|
-
* `/claw`-rooted paths are rewritten in lockstep.
|
|
850
|
-
*/
|
|
851
|
-
function migrateClawToAgent(manifest: ServicesManifest): {
|
|
852
|
-
manifest: ServicesManifest;
|
|
853
|
-
changed: boolean;
|
|
854
|
-
} {
|
|
855
|
-
let changed = false;
|
|
856
|
-
const services = manifest.services.map((entry) => {
|
|
857
|
-
if (entry.name !== "claw" || entry.paths[0] !== "/claw") return entry;
|
|
858
|
-
changed = true;
|
|
859
|
-
const next: ServiceEntry = {
|
|
860
|
-
...entry,
|
|
861
|
-
name: "agent",
|
|
862
|
-
paths: entry.paths.map((p) => rewriteClawPath(p)),
|
|
863
|
-
health: rewriteClawPath(entry.health),
|
|
864
|
-
};
|
|
865
|
-
return next;
|
|
866
|
-
});
|
|
867
|
-
return { manifest: { services }, changed };
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function rewriteClawPath(p: string): string {
|
|
871
|
-
if (p === "/claw") return "/agent";
|
|
872
|
-
if (p.startsWith("/claw/")) return `/agent${p.slice("/claw".length)}`;
|
|
873
|
-
return p;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
936
|
export function writeManifest(
|
|
877
937
|
manifest: ServicesManifest,
|
|
878
938
|
path: string = SERVICES_MANIFEST_PATH,
|
package/src/sessions.ts
CHANGED
|
@@ -4,12 +4,21 @@
|
|
|
4
4
|
* with that cookie skip the login form and go straight to consent.
|
|
5
5
|
*
|
|
6
6
|
* Stored in `sessions` (one row per active session), so logout / forced
|
|
7
|
-
* revocation is just a delete. Sessions are
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* ~10 min while a tab
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* revocation is just a delete. Sessions are ROLLING (hub-parity P1, Q4 —
|
|
8
|
+
* adopts cloud's posture): `expires_at` starts at `created_at + SESSION_TTL_MS`
|
|
9
|
+
* (90 days), and {@link touchSession} pushes it forward on genuine activity
|
|
10
|
+
* (the admin SPA re-mints `/admin/host-admin-token` every ~10 min while a tab
|
|
11
|
+
* is open; `GET /account/session`, the app's boot/poll oracle, slides once it
|
|
12
|
+
* crosses {@link SESSION_SLIDE_THRESHOLD_MS} of its life — see
|
|
13
|
+
* `account-session.ts`). There is NO absolute ceiling: an ACTIVELY-used
|
|
14
|
+
* session rolls forward indefinitely, while an idle one (no more touches)
|
|
15
|
+
* still lapses at the 90-day mark. Was 24h sliding / 30d hard cap through
|
|
16
|
+
* 2026-07; the cap is gone. The ONLY thing that terminates a session row today
|
|
17
|
+
* is logout (which deletes it). Note what does NOT: the admin screen-lock gates
|
|
18
|
+
* token MINTS (it does not touch or expire the session row), and per-user
|
|
19
|
+
* session revocation does not exist yet (P6-era). So an actively-used session
|
|
20
|
+
* — including a stolen cookie — currently lives on until 90 idle days or an
|
|
21
|
+
* explicit logout; a per-user revoke lever is the standing gap to close.
|
|
13
22
|
*
|
|
14
23
|
* The cookie value is the session id directly. It's a 32-byte base64url
|
|
15
24
|
* random; collision is statistically impossible. No HMAC needed because the
|
|
@@ -20,16 +29,25 @@ import type { Database } from "bun:sqlite";
|
|
|
20
29
|
import { randomBytes } from "node:crypto";
|
|
21
30
|
|
|
22
31
|
export const SESSION_COOKIE_NAME = "parachute_hub_session";
|
|
23
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Session lifetime — 90 days (hub-parity P1, Q4: adopts cloud's rolling
|
|
34
|
+
* posture). A session (and its cookie Max-Age) lasts 90 days from its last
|
|
35
|
+
* slide; {@link findSession} still expires it hard past this, and logout
|
|
36
|
+
* deletes the row outright. Was 24h (sliding, 30d hard cap) prior to this PR.
|
|
37
|
+
*/
|
|
38
|
+
export const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
24
39
|
|
|
25
40
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
41
|
+
* Roll a session forward once it has been used past this much of its life
|
|
42
|
+
* (30 days) — so an ACTIVE session stays alive indefinitely, while an idle one
|
|
43
|
+
* still expires at the full 90-day TTL. Callers that slide on every touch
|
|
44
|
+
* (the admin SPA's host-admin-token re-mint) don't need this threshold; it's
|
|
45
|
+
* for a POLLING caller like `GET /account/session` (the app's `/check-email`
|
|
46
|
+
* screen hits it every few seconds) that must NOT rewrite the row on every
|
|
47
|
+
* request — see `shouldSlideSession` there. Mirrors cloud's
|
|
48
|
+
* `SESSION_REFRESH_THRESHOLD_MS` (`workers/identity/src/sessions.ts`).
|
|
31
49
|
*/
|
|
32
|
-
export const
|
|
50
|
+
export const SESSION_SLIDE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
33
51
|
|
|
34
52
|
export interface Session {
|
|
35
53
|
id: string;
|
|
@@ -91,30 +109,33 @@ export function findSession(
|
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
/**
|
|
94
|
-
* Slide a session's expiry forward to `now + SESSION_TTL_MS
|
|
95
|
-
*
|
|
112
|
+
* Slide a session's expiry forward to `now + SESSION_TTL_MS`. No-op when the
|
|
113
|
+
* session doesn't exist. NO ceiling (hub-parity P1, Q4) — cloud's posture,
|
|
114
|
+
* adopted here: an ACTIVELY-used session rolls forward forever; a closed tab
|
|
115
|
+
* / idle client (no more touches) still expires `SESSION_TTL_MS` after its
|
|
116
|
+
* last activity. The old absolute cap (`SESSION_MAX_LIFETIME_MS`, 30d) is
|
|
117
|
+
* removed — the bounds on a rolling session are logout, the admin
|
|
118
|
+
* screen-lock, and (P6-era) per-user delete, not a lifetime ceiling.
|
|
96
119
|
*
|
|
97
|
-
* This is what makes sessions sliding rather than fixed-
|
|
98
|
-
* re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
|
|
99
|
-
* and each successful mint calls this
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
120
|
+
* This is what makes sessions sliding rather than fixed-TTL: the admin SPA
|
|
121
|
+
* re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
|
|
122
|
+
* open, and each successful mint calls this unconditionally; `GET
|
|
123
|
+
* /account/session` (the app's boot/poll oracle) instead calls this only
|
|
124
|
+
* past `SESSION_SLIDE_THRESHOLD_MS` of remaining life (bounded slide — see
|
|
125
|
+
* `account-session.ts`), since it's polled every few seconds and an
|
|
126
|
+
* unconditional touch there would rewrite the row on every poll.
|
|
103
127
|
*
|
|
104
128
|
* Monotonic in practice: the production wall clock only moves forward, so the
|
|
105
|
-
* slid value never undershoots a previously-written expiry
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
129
|
+
* slid value never undershoots a previously-written expiry. (The write is
|
|
130
|
+
* unconditional — it does not read the current expiry — so an injected
|
|
131
|
+
* backward `now` in tests would shorten the session: a conservative failure
|
|
132
|
+
* mode, not a security issue.) `now` is injectable for tests, matching
|
|
133
|
+
* {@link findSession}.
|
|
110
134
|
*/
|
|
111
135
|
export function touchSession(db: Database, id: string, now: () => Date = () => new Date()): void {
|
|
112
136
|
const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
113
137
|
if (!row) return;
|
|
114
|
-
const
|
|
115
|
-
const slidMs = nowMs + SESSION_TTL_MS;
|
|
116
|
-
const ceilingMs = new Date(row.created_at).getTime() + SESSION_MAX_LIFETIME_MS;
|
|
117
|
-
const newExpiresAt = new Date(Math.min(slidMs, ceilingMs)).toISOString();
|
|
138
|
+
const newExpiresAt = new Date(now().getTime() + SESSION_TTL_MS).toISOString();
|
|
118
139
|
db.prepare("UPDATE sessions SET expires_at = ? WHERE id = ?").run(newExpiresAt, id);
|
|
119
140
|
}
|
|
120
141
|
|
|
@@ -122,6 +143,21 @@ export function deleteSession(db: Database, id: string): void {
|
|
|
122
143
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
123
144
|
}
|
|
124
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Should this live session be rolled forward? True once it has been used past
|
|
148
|
+
* {@link SESSION_SLIDE_THRESHOLD_MS} of its life — i.e. its remaining life has
|
|
149
|
+
* dropped below `SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS`. A
|
|
150
|
+
* freshly-created/-slid session is NOT slid; one that's crossed the threshold
|
|
151
|
+
* is. Pure — the caller (`account-session.ts`'s bounded slide, the G3 twin of
|
|
152
|
+
* cloud's `shouldSlideSession`) does the write ({@link touchSession}) + cookie
|
|
153
|
+
* re-issue only when this returns true, bounding both to ~once per threshold
|
|
154
|
+
* per session even under frequent polling.
|
|
155
|
+
*/
|
|
156
|
+
export function shouldSlideSession(session: Session, now: () => Date = () => new Date()): boolean {
|
|
157
|
+
const remainingMs = new Date(session.expiresAt).getTime() - now().getTime();
|
|
158
|
+
return remainingMs < SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS;
|
|
159
|
+
}
|
|
160
|
+
|
|
125
161
|
/**
|
|
126
162
|
* Build a `Set-Cookie` header value for the given session id. HttpOnly +
|
|
127
163
|
* SameSite=Lax + Secure (conditional) + Path=/.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/surface/notes` → `/surface/parachute` conditional alias (W2-12).
|
|
3
|
+
*
|
|
4
|
+
* The app's surface identity renamed from `notes` to `parachute`
|
|
5
|
+
* (parachute-app W2-12): a FUTURE install of the renamed surface package
|
|
6
|
+
* mounts at `/surface/parachute`, while every EXISTING install keeps
|
|
7
|
+
* serving notes-ui at `/surface/notes` — mounts are per-install on-disk
|
|
8
|
+
* identities, so nothing moves until an operator re-installs.
|
|
9
|
+
*
|
|
10
|
+
* The failure this alias exists for: an in-place upgrade that re-adds the
|
|
11
|
+
* renamed package over an existing `notes` instance flips the mount to
|
|
12
|
+
* `/surface/parachute`, orphaning every `/surface/notes/*` bookmark, PWA
|
|
13
|
+
* install, and in-vault link. This helper is the safety net — when (and
|
|
14
|
+
* only when) the manifest shows the legacy mount GONE and the new mount
|
|
15
|
+
* PRESENT, `/surface/notes/*` 301s to `/surface/parachute/*` with the
|
|
16
|
+
* sub-path tail and query string preserved.
|
|
17
|
+
*
|
|
18
|
+
* Condition — decided on the `uis{}` sub-units' **mount paths**, not their
|
|
19
|
+
* map keys:
|
|
20
|
+
*
|
|
21
|
+
* - fire ⇔ no `uis{}` sub-unit (across every services.json row) is
|
|
22
|
+
* mounted at or under `/surface/notes`, AND some sub-unit is
|
|
23
|
+
* mounted at exactly `/surface/parachute`.
|
|
24
|
+
*
|
|
25
|
+
* The W2-12 plan phrased this as "uis has no `notes` key and has a
|
|
26
|
+
* `parachute` key". Keys and paths coincide in the default install shape
|
|
27
|
+
* (surface-host keys `uis{}` by the surface's effective `meta.name` and
|
|
28
|
+
* defaults the mount to `/surface/<name>` — see parachute-surface
|
|
29
|
+
* admin-routes.ts `buildUisExtraField`), but they diverge exactly in the
|
|
30
|
+
* scenario this alias protects: re-adding the renamed package with
|
|
31
|
+
* `instance_name=notes` and no `mount_path` keeps the key `notes` while
|
|
32
|
+
* the PATH flips to `/surface/parachute`. A key-based condition would sit
|
|
33
|
+
* inert there and let the bookmarks 404; the path-based condition asks the
|
|
34
|
+
* routing question directly (the same `ui.path` values `resolveUiMount`
|
|
35
|
+
* matches on), so it fires precisely when `/surface/notes` stopped
|
|
36
|
+
* resolving and `/surface/parachute` started. It also can never redirect
|
|
37
|
+
* INTO a 404: the target mount's existence is part of the condition.
|
|
38
|
+
*
|
|
39
|
+
* INERT TODAY on every live install, on both branches of the condition:
|
|
40
|
+
* an existing notes-ui install either carries a `uis{}` sub-unit mounted
|
|
41
|
+
* at `/surface/notes` (legacy-mount-present → no redirect) or — as on
|
|
42
|
+
* installs whose surface-host row predates `uis{}` self-registration —
|
|
43
|
+
* carries no `/surface/parachute` mount (new-mount-absent → no redirect).
|
|
44
|
+
* Nothing fires until a surface actually registers at `/surface/parachute`.
|
|
45
|
+
*
|
|
46
|
+
* Dispatch placement (hub-server.ts): immediately BEFORE the generic
|
|
47
|
+
* services.json proxy — the same spot in the order where `resolveUiMount`
|
|
48
|
+
* reads the manifest — so every hub-owned prefix has had its turn and a
|
|
49
|
+
* live `/surface/notes` mount is never preempted (the condition already
|
|
50
|
+
* guarantees that, but the ordering keeps the alias out of the hot path
|
|
51
|
+
* for all non-`/surface/notes` traffic).
|
|
52
|
+
*
|
|
53
|
+
* Relationship to `notes-redirect.ts`: that module bridges the OLDER
|
|
54
|
+
* `/notes/*` → `/surface/notes/*` rename (notes-as-app Phase 2) and is
|
|
55
|
+
* untouched by W2-12 — its target keeps serving notes-ui on existing
|
|
56
|
+
* installs. On a post-rename install the two compose: `/notes/x` → 301
|
|
57
|
+
* `/surface/notes/x` → 301 `/surface/parachute/x`.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import type { ServiceEntry } from "./services-manifest.ts";
|
|
61
|
+
|
|
62
|
+
/** The legacy mount — the app's pre-W2-12 surface identity (notes-ui). */
|
|
63
|
+
export const LEGACY_SURFACE_NOTES_MOUNT = "/surface/notes";
|
|
64
|
+
|
|
65
|
+
/** The app's surface mount — the post-W2-12 identity. */
|
|
66
|
+
export const APP_SURFACE_PARACHUTE_MOUNT = "/surface/parachute";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Matches the legacy app-surface mount: bare `/surface/notes`, the
|
|
70
|
+
* trailing-slash form, and any sub-path. Boundary-checked — `/surface/notesy`
|
|
71
|
+
* and `/surface/notes-archive` do NOT match. Same shape as
|
|
72
|
+
* `isLegacyNotesPath` in notes-redirect.ts.
|
|
73
|
+
*/
|
|
74
|
+
export function isSurfaceNotesPath(pathname: string): boolean {
|
|
75
|
+
return (
|
|
76
|
+
pathname === LEGACY_SURFACE_NOTES_MOUNT || pathname.startsWith(`${LEGACY_SURFACE_NOTES_MOUNT}/`)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide whether a `/surface/notes*` request should 301 to the
|
|
82
|
+
* `/surface/parachute*` twin. Returns the target (path + preserved query)
|
|
83
|
+
* when the condition holds, `undefined` otherwise.
|
|
84
|
+
*
|
|
85
|
+
* `services` is the lenient manifest read (`readManifestLenient(...).services`)
|
|
86
|
+
* — the same source `resolveUiMount` consumes, re-read per request so the
|
|
87
|
+
* alias tracks a surface-host re-registration without a hub restart.
|
|
88
|
+
*
|
|
89
|
+
* Normalization mirrors `resolveUiMount`: trailing slashes stripped before
|
|
90
|
+
* comparison. The legacy-mount check treats a sub-unit mounted at OR UNDER
|
|
91
|
+
* `/surface/notes` as "the legacy identity still resolves" (a deeper mount
|
|
92
|
+
* like `/surface/notes/foo` would still serve real content under the
|
|
93
|
+
* prefix — redirecting across it would hijack a live route). The target
|
|
94
|
+
* check requires an EXACT `/surface/parachute` mount — a deeper-only mount
|
|
95
|
+
* would leave the redirect landing on a 404, which this alias exists to
|
|
96
|
+
* prevent, not cause.
|
|
97
|
+
*/
|
|
98
|
+
export function maybeRedirectSurfaceNotes(
|
|
99
|
+
pathname: string,
|
|
100
|
+
search: string,
|
|
101
|
+
services: readonly ServiceEntry[],
|
|
102
|
+
): string | undefined {
|
|
103
|
+
if (!isSurfaceNotesPath(pathname)) return undefined;
|
|
104
|
+
|
|
105
|
+
let legacyMountResolves = false;
|
|
106
|
+
let appMountPresent = false;
|
|
107
|
+
for (const entry of services) {
|
|
108
|
+
if (!entry.uis) continue;
|
|
109
|
+
for (const ui of Object.values(entry.uis)) {
|
|
110
|
+
const norm = ui.path.replace(/\/+$/, "") || "/";
|
|
111
|
+
if (
|
|
112
|
+
norm === LEGACY_SURFACE_NOTES_MOUNT ||
|
|
113
|
+
norm.startsWith(`${LEGACY_SURFACE_NOTES_MOUNT}/`)
|
|
114
|
+
) {
|
|
115
|
+
legacyMountResolves = true;
|
|
116
|
+
}
|
|
117
|
+
if (norm === APP_SURFACE_PARACHUTE_MOUNT) {
|
|
118
|
+
appMountPresent = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (legacyMountResolves || !appMountPresent) return undefined;
|
|
123
|
+
|
|
124
|
+
const tail = pathname.slice(LEGACY_SURFACE_NOTES_MOUNT.length);
|
|
125
|
+
return `${APP_SURFACE_PARACHUTE_MOUNT}${tail}${search}`;
|
|
126
|
+
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the agent UI session→bearer mint endpoint. Mirrors
|
|
3
|
-
* `admin-host-admin-token.test.ts` shape (agent 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: "agent"` and `agent:read agent:send agent: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 { AGENT_TOKEN_TTL_SECONDS, handleAgentToken } from "../admin-agent-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-agent-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("handleAgentToken", () => {
|
|
83
|
-
test("401 when no session cookie is present", async () => {
|
|
84
|
-
const req = new Request(`${ISSUER}/admin/agent-token`);
|
|
85
|
-
const res = await handleAgentToken(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/agent-token`, { headers: { cookie } });
|
|
96
|
-
const res = await handleAgentToken(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/agent-token`, {
|
|
103
|
-
method: "POST",
|
|
104
|
-
headers: { cookie },
|
|
105
|
-
});
|
|
106
|
-
const res = await handleAgentToken(req, { db: harness.db, issuer: ISSUER });
|
|
107
|
-
expect(res.status).toBe(405);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("200 mints a JWT carrying aud:agent + agent:read agent:send agent:admin", async () => {
|
|
111
|
-
const { cookie, userId } = await withSession();
|
|
112
|
-
rotateSigningKey(harness.db);
|
|
113
|
-
const req = new Request(`${ISSUER}/admin/agent-token`, { headers: { cookie } });
|
|
114
|
-
const res = await handleAgentToken(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
|
-
// `agent:send` (post) + `agent:read` (SSE replies) + `agent:admin`
|
|
120
|
-
// (config UI list/edit — 2026-06-09 modular-UI architecture P3). Deliberately
|
|
121
|
-
// NOT `agent:write` — that's the session-reply scope a UI token must not hold.
|
|
122
|
-
expect(body.scopes).toEqual(["agent:read", "agent:send", "agent:admin"]);
|
|
123
|
-
expect(body.scopes).not.toContain("agent: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((AGENT_TOKEN_TTL_SECONDS - 30) * 1000);
|
|
129
|
-
expect(skew).toBeLessThan((AGENT_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 — agent validates `aud === "agent"`
|
|
135
|
-
// (parachute-agent src/hub-jwt.ts).
|
|
136
|
-
expect(validated.payload.aud).toBe("agent");
|
|
137
|
-
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
138
|
-
const scopes = scopeClaim.split(/\s+/);
|
|
139
|
-
expect(scopes).toContain("agent:read");
|
|
140
|
-
expect(scopes).toContain("agent:send");
|
|
141
|
-
expect(scopes).toContain("agent:admin");
|
|
142
|
-
expect(scopes).not.toContain("agent: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/agent-token`, {
|
|
152
|
-
headers: { cookie: friendCookie },
|
|
153
|
-
});
|
|
154
|
-
const res = await handleAgentToken(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/agent-token`, {
|
|
165
|
-
headers: { cookie: adminCookie },
|
|
166
|
-
});
|
|
167
|
-
const res = await handleAgentToken(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
|
-
});
|