@openparachute/hub 0.7.5 → 0.7.6-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-module-token.test.ts +40 -3
- package/src/__tests__/api-modules-ops.test.ts +8 -3
- package/src/__tests__/api-modules.test.ts +26 -18
- package/src/__tests__/connections-store.test.ts +84 -0
- package/src/__tests__/doctor.test.ts +131 -0
- package/src/__tests__/git-notify.test.ts +29 -1
- package/src/__tests__/grants-store.test.ts +33 -1
- package/src/__tests__/hub-instance.test.ts +297 -0
- package/src/__tests__/hub-server.test.ts +169 -0
- package/src/__tests__/install.test.ts +28 -0
- package/src/__tests__/serve-boot.test.ts +60 -0
- package/src/__tests__/service-spec-discovery.test.ts +32 -9
- package/src/__tests__/setup.test.ts +64 -16
- package/src/__tests__/stale-module-units.test.ts +1 -1
- package/src/__tests__/status-supervisor.test.ts +112 -0
- package/src/admin-connections.ts +5 -1
- package/src/admin-module-token.ts +2 -2
- package/src/api-modules-ops.ts +3 -3
- package/src/api-modules.ts +13 -13
- package/src/commands/doctor.ts +167 -4
- package/src/commands/install.ts +29 -3
- package/src/commands/migrate.ts +5 -0
- package/src/commands/serve.ts +52 -0
- package/src/commands/setup.ts +10 -9
- package/src/commands/status.ts +42 -1
- package/src/connections-store.ts +15 -2
- package/src/git-notify.ts +34 -5
- package/src/grants-store.ts +15 -2
- package/src/help.ts +3 -3
- package/src/hub-instance.ts +365 -0
- package/src/hub-server.ts +89 -1
- package/src/install-source.ts +1 -1
- package/src/service-spec.ts +36 -44
- package/src/services-manifest.ts +1 -1
- package/src/stale-module-units.ts +2 -2
- package/src/well-known.ts +3 -3
package/src/commands/install.ts
CHANGED
|
@@ -65,7 +65,7 @@ export type Runner = (cmd: readonly string[]) => Promise<number>;
|
|
|
65
65
|
* Rationale: the canonical Render deploy ships the hub container from
|
|
66
66
|
* `main` (which tracks the rc chain per governance rule 2). Without this
|
|
67
67
|
* env var the supervisor's `/admin/modules` install API would still
|
|
68
|
-
* resolve `@latest` for vault /
|
|
68
|
+
* resolve `@latest` for vault / surface / scribe — leaving a hub-on-rc
|
|
69
69
|
* cluster bootstrapping its other modules on stable, which silently
|
|
70
70
|
* fragments the cluster's version axis. Setting `PARACHUTE_INSTALL_CHANNEL=rc`
|
|
71
71
|
* at the platform level cascades the rc-ness across every module install,
|
|
@@ -137,6 +137,22 @@ const SERVICE_ALIASES: Record<string, string> = {
|
|
|
137
137
|
channel: "agent",
|
|
138
138
|
};
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Former first-party shorts that were RETIRED from the registries (not
|
|
142
|
+
* renamed — no alias target). Without this guard the bare short would fall
|
|
143
|
+
* through resolveInstallTarget's "anything else is npm" arm and `bun add -g`
|
|
144
|
+
* an UNRELATED npm package that happens to share the name (`runner` is a
|
|
145
|
+
* real, non-Parachute package on npm). Install refuses with the message
|
|
146
|
+
* instead.
|
|
147
|
+
*/
|
|
148
|
+
const RETIRED_INSTALL_SHORTS: Record<string, string> = {
|
|
149
|
+
runner:
|
|
150
|
+
"parachute-runner was retired from the hub's module registry on 2026-07-01 " +
|
|
151
|
+
"(the module set of record is vault, hub, agent, scribe, surface). " +
|
|
152
|
+
"An existing install keeps running under `parachute serve`; to install it anyway, " +
|
|
153
|
+
"pass the explicit npm package name (@openparachute/runner) or a local checkout path.",
|
|
154
|
+
};
|
|
155
|
+
|
|
140
156
|
export interface InstallOpts {
|
|
141
157
|
runner?: Runner;
|
|
142
158
|
manifestPath?: string;
|
|
@@ -625,7 +641,8 @@ async function readInstalledManifest(
|
|
|
625
641
|
* - **fallback**: notes / channel still ship a vendored manifest + extras
|
|
626
642
|
* in FIRST_PARTY_FALLBACKS. Missing `module.json` is non-fatal — the
|
|
627
643
|
* embedded manifest carries the install through.
|
|
628
|
-
* - **known**: vault / scribe /
|
|
644
|
+
* - **known**: vault / scribe / agent / surface have retired their FALLBACK
|
|
645
|
+
* entries (runner had too, before its 2026-07-01 registry removal).
|
|
629
646
|
* We know the package + manifestName + imperative extras (init,
|
|
630
647
|
* postInstallFooter, urlForEntry, hasAuth) but NOT the static manifest;
|
|
631
648
|
* `module.json` is the contract and a missing one is a hard error,
|
|
@@ -672,6 +689,15 @@ function resolveInstallTarget(
|
|
|
672
689
|
const aliased = SERVICE_ALIASES[input];
|
|
673
690
|
const candidate = aliased ?? input;
|
|
674
691
|
|
|
692
|
+
// Retired shorts refuse BEFORE the npm fallback: `install runner` must not
|
|
693
|
+
// `bun add -g` npm's unrelated `runner` package. Explicit package names /
|
|
694
|
+
// paths still pass through the arms below.
|
|
695
|
+
const retiredMessage = RETIRED_INSTALL_SHORTS[candidate];
|
|
696
|
+
if (retiredMessage !== undefined) {
|
|
697
|
+
log(`✗ ${retiredMessage}`);
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
|
|
675
701
|
const fb = FIRST_PARTY_FALLBACKS[candidate];
|
|
676
702
|
if (fb) {
|
|
677
703
|
if (aliased !== undefined) {
|
|
@@ -943,7 +969,7 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
943
969
|
manifest = installedManifest ?? target.fallback.manifest;
|
|
944
970
|
extras = target.fallback.extras;
|
|
945
971
|
} else if (target.kind === "known-module") {
|
|
946
|
-
// KNOWN_MODULES shorts (vault / scribe /
|
|
972
|
+
// KNOWN_MODULES shorts (vault / scribe / agent / surface) carry no vendored
|
|
947
973
|
// manifest (hub#310). The module's own `.parachute/module.json` is the
|
|
948
974
|
// canonical source. When it's unreadable (legacy installs from before
|
|
949
975
|
// module.json shipped, or test fixtures that mock the disk path without
|
package/src/commands/migrate.ts
CHANGED
|
@@ -77,6 +77,11 @@ export const ARCHIVE_PREFIX = ".archive-";
|
|
|
77
77
|
export function safelistEntries(): Set<string> {
|
|
78
78
|
return new Set<string>([
|
|
79
79
|
...knownServices(),
|
|
80
|
+
// `runner` left the registries on 2026-07-01 (module set of record:
|
|
81
|
+
// vault / hub / agent / scribe / surface) but legacy installs still have
|
|
82
|
+
// a `~/.parachute/runner/` dir — keep it safelisted so it doesn't count
|
|
83
|
+
// as unrecognized-root noise in `migrateNotice`.
|
|
84
|
+
"runner",
|
|
80
85
|
"hub",
|
|
81
86
|
"services.json",
|
|
82
87
|
"expose-state.json",
|
package/src/commands/serve.ts
CHANGED
|
@@ -36,6 +36,14 @@ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
|
36
36
|
import { readExposeState } from "../expose-state.ts";
|
|
37
37
|
import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../hub-db-liveness.ts";
|
|
38
38
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
39
|
+
import {
|
|
40
|
+
type HubInstanceRecord,
|
|
41
|
+
type HubSelfProbe,
|
|
42
|
+
armHubSelfProbe,
|
|
43
|
+
clearHubInstanceFile,
|
|
44
|
+
generateInstanceNonce,
|
|
45
|
+
writeHubInstanceFile,
|
|
46
|
+
} from "../hub-instance.ts";
|
|
39
47
|
import { hubFetch } from "../hub-server.ts";
|
|
40
48
|
import { getHubOrigin } from "../hub-settings.ts";
|
|
41
49
|
import { writeHubFile } from "../hub.ts";
|
|
@@ -513,6 +521,13 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
513
521
|
|
|
514
522
|
const supervisor = opts.supervisor ?? new Supervisor();
|
|
515
523
|
|
|
524
|
+
// Per-boot instance nonce (hub#737). Minted BEFORE the listener so it can be
|
|
525
|
+
// threaded into `/health`; written to disk AFTER a successful bind (below) so
|
|
526
|
+
// a bind failure never leaves a stale identity file. It's the linchpin of
|
|
527
|
+
// loopback-hijack detection: `/health` echoes it, and external tools compare
|
|
528
|
+
// the disk copy to what a loopback `/health` actually returns.
|
|
529
|
+
const instanceNonce = generateInstanceNonce();
|
|
530
|
+
|
|
516
531
|
// Claim the hub port FIRST — before booting a single supervised module. If
|
|
517
532
|
// another hub/supervisor already owns it, `Bun.serve` throws here and we
|
|
518
533
|
// exit immediately. The prior order (boot modules, *then* bind) let a
|
|
@@ -534,6 +549,7 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
534
549
|
probeDbPath: () => dbHolder.probePath(),
|
|
535
550
|
issuer,
|
|
536
551
|
loopbackPort: port,
|
|
552
|
+
instanceNonce,
|
|
537
553
|
supervisor,
|
|
538
554
|
}),
|
|
539
555
|
}),
|
|
@@ -544,6 +560,38 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
544
560
|
throw err;
|
|
545
561
|
}
|
|
546
562
|
|
|
563
|
+
// We own the listener now — record this process's identity on disk (0644) so
|
|
564
|
+
// `parachute status` / `parachute doctor` can detect a loopback hijack by
|
|
565
|
+
// comparing this nonce to what a loopback `/health` returns. Best-effort:
|
|
566
|
+
// a write failure only degrades external detection, never blocks the hub.
|
|
567
|
+
const instanceRecord: HubInstanceRecord = {
|
|
568
|
+
instance: instanceNonce,
|
|
569
|
+
pid: process.pid,
|
|
570
|
+
port,
|
|
571
|
+
startedAt: new Date().toISOString(),
|
|
572
|
+
};
|
|
573
|
+
writeHubInstanceFile(instanceRecord, { configDir: CONFIG_DIR, log });
|
|
574
|
+
|
|
575
|
+
// Arm the loopback self-probe (hub#737): an immediate check right after the
|
|
576
|
+
// bind catches a hijack that's ALREADY present at boot (the OrbStack VM that
|
|
577
|
+
// relaunched at reboot and grabbed 127.0.0.1:<port> before us), then a
|
|
578
|
+
// low-frequency re-probe catches one that appears later. It logs loudly on a
|
|
579
|
+
// mismatch and records the verdict into hub-instance.json for external tools.
|
|
580
|
+
// Skipped alongside module boot in tests (`skipModuleBoot`) so the test path
|
|
581
|
+
// never spawns a real 5-minute timer / loopback fetch.
|
|
582
|
+
let selfProbe: HubSelfProbe | undefined;
|
|
583
|
+
if (!opts.skipModuleBoot) {
|
|
584
|
+
selfProbe = armHubSelfProbe(
|
|
585
|
+
{ port, nonce: instanceNonce, record: instanceRecord, configDir: CONFIG_DIR },
|
|
586
|
+
{ log },
|
|
587
|
+
);
|
|
588
|
+
// Fire the startup check without blocking serve's return — a hijack present
|
|
589
|
+
// at boot surfaces within the probe's bounded timeout, not on the next tick.
|
|
590
|
+
// `probeOnce` is non-throwing in production, but guard the floating promise
|
|
591
|
+
// against ever surfacing as an unhandled rejection.
|
|
592
|
+
void selfProbe.probeOnce().catch(() => {});
|
|
593
|
+
}
|
|
594
|
+
|
|
547
595
|
log(
|
|
548
596
|
formatListeningBanner({
|
|
549
597
|
hostname,
|
|
@@ -618,8 +666,12 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
618
666
|
for (const state of supervisor.list()) {
|
|
619
667
|
await supervisor.stop(state.short);
|
|
620
668
|
}
|
|
669
|
+
selfProbe?.stop();
|
|
621
670
|
livenessTimer.stop();
|
|
622
671
|
await server.stop();
|
|
672
|
+
// Clear our on-disk identity so a cleanly-stopped hub leaves no stale
|
|
673
|
+
// self-probe verdict for `status` / `doctor` to read (hub#737 review).
|
|
674
|
+
clearHubInstanceFile(CONFIG_DIR);
|
|
623
675
|
dbHolder.get().close();
|
|
624
676
|
},
|
|
625
677
|
};
|
package/src/commands/setup.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface SetupOpts {
|
|
|
66
66
|
* Survey row. Pre-install we know manifestName + the optional
|
|
67
67
|
* `urlForEntry` quirk (vault wants `/mcp`, scribe wants the bare port); the
|
|
68
68
|
* full ServiceSpec only exists post-install for KNOWN_MODULES shorts
|
|
69
|
-
* (vault / scribe /
|
|
69
|
+
* (vault / scribe / … — hub#310). The survey uses just these two
|
|
70
70
|
* fields, so a minimal shape avoids the spec round-trip pre-install.
|
|
71
71
|
*/
|
|
72
72
|
interface ServiceChoice {
|
|
@@ -110,17 +110,19 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* Survey ALL known first-party shortnames (vault / notes / scribe / agent /
|
|
113
|
-
*
|
|
113
|
+
* surface) regardless of tier — `installed` is true when the service
|
|
114
114
|
* has a row in services.json. The fresh-install OFFER is narrowed downstream
|
|
115
115
|
* by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
|
|
116
|
-
* notes
|
|
116
|
+
* notes); agent (`experimental`) is flagged exploratory in its blurb
|
|
117
117
|
* but stays offered. Surveying everything keeps `installed` detection complete
|
|
118
118
|
* (the "already installed" banner still lists a deprecated module an operator
|
|
119
|
-
* has on disk).
|
|
119
|
+
* has on disk). A legacy `parachute-runner` row is NOT surveyed (runner left
|
|
120
|
+
* the registries 2026-07-01 — see the KNOWN_MODULES note in service-spec.ts);
|
|
121
|
+
* like any unknown row it neither blocks setup nor appears in the offer.
|
|
120
122
|
*
|
|
121
123
|
* The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
|
|
122
124
|
* shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
|
|
123
|
-
* (vault / scribe /
|
|
125
|
+
* (vault / scribe / agent / surface) ship `.parachute/module.json`
|
|
124
126
|
* and self-register; pre-install we know manifestName + the urlForEntry quirk
|
|
125
127
|
* from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
|
|
126
128
|
*/
|
|
@@ -157,7 +159,7 @@ function surveyServices(manifestPath: string): ServiceChoice[] {
|
|
|
157
159
|
/**
|
|
158
160
|
* A surveyed service is OFFERED on a fresh setup iff it is not already
|
|
159
161
|
* installed AND its discovery tier is not `deprecated` (2026-06-25). The
|
|
160
|
-
* deprecated tier (notes-daemon
|
|
162
|
+
* deprecated tier (notes-daemon) stays resolvable + manageable for an
|
|
161
163
|
* existing install — it just isn't pushed on a fresh box. `agent`
|
|
162
164
|
* (`experimental`) is still offered. Exported so the setup tests can pin the
|
|
163
165
|
* exclusion directly.
|
|
@@ -175,11 +177,10 @@ const BLURBS: Record<string, string> = {
|
|
|
175
177
|
surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
|
|
176
178
|
// `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
|
|
177
179
|
app: "Parachute UI host — auto-installs Notes on first boot (recommended over notes-daemon)",
|
|
178
|
-
// notes
|
|
179
|
-
//
|
|
180
|
+
// notes is `deprecated` (not offered on a fresh setup) — this blurb only
|
|
181
|
+
// renders if a legacy install surfaces it in the survey.
|
|
180
182
|
notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
|
|
181
183
|
scribe: "audio transcription for dictation + recordings",
|
|
182
|
-
runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
|
|
183
184
|
agent:
|
|
184
185
|
"(exploratory) chat with your Claude Code sessions — a channel per session (renamed from channel)",
|
|
185
186
|
};
|
package/src/commands/status.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
|
|
|
2
2
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
3
3
|
import { readHubPort } from "../hub-control.ts";
|
|
4
4
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
5
|
+
import { type SelfProbeState, readHubInstanceFile } from "../hub-instance.ts";
|
|
5
6
|
import {
|
|
6
7
|
HUB_UNIT_DEFAULT_PORT,
|
|
7
8
|
type HubUnitDeps,
|
|
@@ -90,6 +91,16 @@ export interface StatusOpts {
|
|
|
90
91
|
openDb?: (configDir: string) => Database;
|
|
91
92
|
/** Loopback hub base URL override (default derives from the hub port). */
|
|
92
93
|
baseUrl?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Read the running serve process's last loopback self-probe verdict from
|
|
96
|
+
* `hub-instance.json` (hub#737). Read from DISK, not over loopback — during
|
|
97
|
+
* a hijack the loopback /health (and the module-ops API) reach the WRONG
|
|
98
|
+
* hub, so the on-disk verdict the real serve wrote is the only trustworthy
|
|
99
|
+
* source. A `hijacked` verdict overrides the hub row (which would otherwise
|
|
100
|
+
* read `active` off the rogue's 200). Default {@link readHubInstanceFile}'s
|
|
101
|
+
* `selfProbe`; tests inject a state (or undefined).
|
|
102
|
+
*/
|
|
103
|
+
readInstanceState?: (configDir: string) => SelfProbeState | undefined;
|
|
93
104
|
};
|
|
94
105
|
}
|
|
95
106
|
|
|
@@ -386,6 +397,7 @@ interface ResolvedStatusSupervisor {
|
|
|
386
397
|
probeModuleHealth: (port: number, health: string) => Promise<boolean>;
|
|
387
398
|
openDb: (configDir: string) => Database;
|
|
388
399
|
baseUrl: string | undefined;
|
|
400
|
+
readInstanceState: (configDir: string) => SelfProbeState | undefined;
|
|
389
401
|
}
|
|
390
402
|
|
|
391
403
|
/**
|
|
@@ -402,6 +414,8 @@ function resolveStatusSupervisor(opts: StatusOpts["supervisor"]): ResolvedStatus
|
|
|
402
414
|
probeModuleHealth: opts?.probeModuleHealth ?? defaultProbeModuleHealth,
|
|
403
415
|
openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
|
|
404
416
|
baseUrl: opts?.baseUrl,
|
|
417
|
+
readInstanceState:
|
|
418
|
+
opts?.readInstanceState ?? ((configDir) => readHubInstanceFile(configDir)?.selfProbe),
|
|
405
419
|
};
|
|
406
420
|
}
|
|
407
421
|
|
|
@@ -671,8 +685,35 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
671
685
|
port,
|
|
672
686
|
hubHealthy,
|
|
673
687
|
});
|
|
688
|
+
// Loopback-hijack override (hub#737). During a hijack the loopback `/health`
|
|
689
|
+
// the hub row's liveness probe hit belongs to the ROGUE hub (a 200 → the row
|
|
690
|
+
// reads `active`), so trust the running serve's own on-disk self-probe verdict
|
|
691
|
+
// instead — read from disk, never over the hijacked loopback. A `hijacked`
|
|
692
|
+
// verdict flips the row to `failing` with the loud, actionable note.
|
|
693
|
+
//
|
|
694
|
+
// GATED ON `hubHealthy` (review fix): the instance file is written per-boot
|
|
695
|
+
// and only cleared on a *graceful* stop, so a hard-killed hub can leave a
|
|
696
|
+
// stale `hijacked` verdict on disk. `hubHealthy` is true exactly when
|
|
697
|
+
// SOMETHING is answering the loopback port right now — which is precisely the
|
|
698
|
+
// live-hijack condition (the rogue keeps answering 200), so gating here never
|
|
699
|
+
// suppresses a real hijack, but it does keep a stopped hub (nothing answering)
|
|
700
|
+
// from rendering a phantom hijack over its normal down-hub row.
|
|
701
|
+
let selfProbe: SelfProbeState | undefined;
|
|
702
|
+
try {
|
|
703
|
+
selfProbe = sup.readInstanceState(configDir);
|
|
704
|
+
} catch {
|
|
705
|
+
selfProbe = undefined;
|
|
706
|
+
}
|
|
707
|
+
if (hubHealthy && selfProbe?.status === "hijacked") {
|
|
708
|
+
hub.stateLabel = "failing";
|
|
709
|
+
hub.healthy = false;
|
|
710
|
+
hub.skipped = false;
|
|
711
|
+
hub.healthDetail = "loopback hijacked — /health answered by a foreign process";
|
|
712
|
+
hub.managerNote = `LOOPBACK HIJACK on :${port} — module JWKS/API calls are NOT reaching this hub. Run \`parachute doctor\` and \`lsof -nP -iTCP:${port} -sTCP:LISTEN\`.`;
|
|
713
|
+
}
|
|
674
714
|
// If the degraded-read note never landed on a module row (empty manifest),
|
|
675
|
-
// surface it on the hub row so the operator still sees the actionable hint
|
|
715
|
+
// surface it on the hub row so the operator still sees the actionable hint —
|
|
716
|
+
// unless the hijack note already claimed it (the hijack is the bigger signal).
|
|
676
717
|
if (moduleReadNote && !hub.managerNote) hub.managerNote = moduleReadNote;
|
|
677
718
|
rows.push(hub);
|
|
678
719
|
return rows;
|
package/src/connections-store.ts
CHANGED
|
@@ -113,12 +113,22 @@ export interface ConnectionRecord {
|
|
|
113
113
|
readonly requestedBy?: string;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* On-disk schema version stamped on every write (2026-07-01). Readers treat a
|
|
118
|
+
* file WITHOUT a `version` field as v1 — every connections.json written before
|
|
119
|
+
* this field existed is a v1 file, so absence tolerance is the whole
|
|
120
|
+
* back-compat story. No migration logic exists today; the field is here so a
|
|
121
|
+
* FUTURE shape change can branch on it instead of sniffing record shapes.
|
|
122
|
+
*/
|
|
123
|
+
export const CONNECTIONS_FILE_VERSION = 1;
|
|
124
|
+
|
|
116
125
|
interface ConnectionsFile {
|
|
126
|
+
version: number;
|
|
117
127
|
connections: ConnectionRecord[];
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
function emptyFile(): ConnectionsFile {
|
|
121
|
-
return { connections: [] };
|
|
131
|
+
return { version: CONNECTIONS_FILE_VERSION, connections: [] };
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
/** Read the store. A missing/garbage file reads as empty (fresh hub). */
|
|
@@ -136,6 +146,9 @@ export function readConnections(storePath: string): ConnectionRecord[] {
|
|
|
136
146
|
return [];
|
|
137
147
|
}
|
|
138
148
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
149
|
+
// `version` is deliberately NOT validated here: an absent field is a legacy
|
|
150
|
+
// v1 file (see CONNECTIONS_FILE_VERSION) and there is only one version
|
|
151
|
+
// today. When v2 lands, this is where the migration branches.
|
|
139
152
|
const arr = (parsed as { connections?: unknown }).connections;
|
|
140
153
|
if (!Array.isArray(arr)) return [];
|
|
141
154
|
// Lenient: drop any malformed row rather than failing the whole read, so one
|
|
@@ -156,7 +169,7 @@ export function readConnections(storePath: string): ConnectionRecord[] {
|
|
|
156
169
|
|
|
157
170
|
function writeAll(storePath: string, records: ConnectionRecord[]): void {
|
|
158
171
|
mkdirSync(dirname(storePath), { recursive: true });
|
|
159
|
-
const file: ConnectionsFile = { connections: records };
|
|
172
|
+
const file: ConnectionsFile = { version: CONNECTIONS_FILE_VERSION, connections: records };
|
|
160
173
|
// Written WITHOUT 0o600 because this file holds NO secrets — the provisioned
|
|
161
174
|
// webhook bearer lives only in the vault trigger's row, never here; records
|
|
162
175
|
// carry source/sink/trigger-name metadata only. Consistent with the default
|
package/src/git-notify.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* validates when it comes back in over loopback.
|
|
32
32
|
*/
|
|
33
33
|
import type { Database } from "bun:sqlite";
|
|
34
|
+
import { REGISTERED_MINT_TTL_THRESHOLD_SECONDS } from "./admin-connections.ts";
|
|
34
35
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
35
36
|
|
|
36
37
|
/** Provenance identity stamped on the hub-internal notify + pull tokens. */
|
|
@@ -43,18 +44,46 @@ const SURFACE_AUDIENCE = "surface";
|
|
|
43
44
|
/**
|
|
44
45
|
* notify-auth TTL. The POST is fired immediately; a small window covers a
|
|
45
46
|
* momentarily-busy loopback without leaving a usable credential lying around.
|
|
47
|
+
*
|
|
48
|
+
* Exported for the TTL-policy guard test only.
|
|
46
49
|
*/
|
|
47
|
-
const NOTIFY_TTL_SECONDS = 120;
|
|
50
|
+
export const NOTIFY_TTL_SECONDS = 120;
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* pull-token TTL. Long enough for surface-host to `git clone --depth 1` a
|
|
51
54
|
* source surface right after the notify lands, short enough that a leaked
|
|
52
55
|
* token is near-useless. Both TTLs here MUST stay well under the hub's
|
|
53
|
-
* registered-mint threshold (
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
+
* registered-mint threshold (`REGISTERED_MINT_TTL_THRESHOLD_SECONDS`, 600s —
|
|
57
|
+
* imported from admin-connections.ts, where the policy lives) so these
|
|
58
|
+
* fire-and-forget tokens remain unregistered-by-policy — bumping either past
|
|
59
|
+
* it without registering them would leak unrevocable tokens. Enforced by
|
|
60
|
+
* {@link assertUnregisteredMintTtl} at module load, not just this comment.
|
|
61
|
+
*
|
|
62
|
+
* Exported for the TTL-policy guard test only.
|
|
56
63
|
*/
|
|
57
|
-
const PULL_TTL_SECONDS = 300;
|
|
64
|
+
export const PULL_TTL_SECONDS = 300;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Registered-mint policy guard (hub-module-boundary charter): a TTL minted
|
|
68
|
+
* WITHOUT a tokens-table registration must stay strictly under the
|
|
69
|
+
* registered-mint threshold. Throws at module load when a future edit bumps
|
|
70
|
+
* one of this file's fire-and-forget TTLs to/past the line — turning a silent
|
|
71
|
+
* "unrevocable token" policy leak into an immediate boot failure.
|
|
72
|
+
*
|
|
73
|
+
* Exported for tests; not for reuse as a general validator (registered mint
|
|
74
|
+
* sites legitimately exceed the threshold — they register the jti instead).
|
|
75
|
+
*/
|
|
76
|
+
export function assertUnregisteredMintTtl(name: string, ttlSeconds: number): void {
|
|
77
|
+
if (ttlSeconds >= REGISTERED_MINT_TTL_THRESHOLD_SECONDS) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`git-notify: ${name} (${ttlSeconds}s) must stay under the registered-mint threshold (${REGISTERED_MINT_TTL_THRESHOLD_SECONDS}s). Tokens minted here are fire-and-forget and never registered in the tokens table — at/above the threshold they'd be long-lived AND unrevocable. Either shorten the TTL or register the mint (see admin-connections.ts registered-mint rule).`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Module-load enforcement of the policy the comments above describe.
|
|
85
|
+
assertUnregisteredMintTtl("NOTIFY_TTL_SECONDS", NOTIFY_TTL_SECONDS);
|
|
86
|
+
assertUnregisteredMintTtl("PULL_TTL_SECONDS", PULL_TTL_SECONDS);
|
|
58
87
|
|
|
59
88
|
/** Bound the notify HTTP call so a wedged surface-host can't hang the caller. */
|
|
60
89
|
const NOTIFY_FETCH_TIMEOUT_MS = 10_000;
|
package/src/grants-store.ts
CHANGED
|
@@ -143,7 +143,17 @@ export interface GrantRecord {
|
|
|
143
143
|
readonly approvedAt?: string;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* On-disk schema version stamped on every write (2026-07-01). Readers treat a
|
|
148
|
+
* file WITHOUT a `version` field as v1 — every agent-grants.json written
|
|
149
|
+
* before this field existed is a v1 file, so absence tolerance is the whole
|
|
150
|
+
* back-compat story. No migration logic exists today; the field is here so a
|
|
151
|
+
* FUTURE shape change can branch on it instead of sniffing record shapes.
|
|
152
|
+
*/
|
|
153
|
+
export const GRANTS_FILE_VERSION = 1;
|
|
154
|
+
|
|
146
155
|
interface GrantsFile {
|
|
156
|
+
version: number;
|
|
147
157
|
grants: GrantRecord[];
|
|
148
158
|
}
|
|
149
159
|
|
|
@@ -194,7 +204,7 @@ export function grantId(agent: string, spec: ConnectionSpec): string {
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
function emptyFile(): GrantsFile {
|
|
197
|
-
return { grants: [] };
|
|
207
|
+
return { version: GRANTS_FILE_VERSION, grants: [] };
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
function isConnectionSpec(v: unknown): v is ConnectionSpec {
|
|
@@ -221,6 +231,9 @@ export function readGrants(storePath: string): GrantRecord[] {
|
|
|
221
231
|
return [];
|
|
222
232
|
}
|
|
223
233
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
234
|
+
// `version` is deliberately NOT validated here: an absent field is a legacy
|
|
235
|
+
// v1 file (see GRANTS_FILE_VERSION) and there is only one version today.
|
|
236
|
+
// When v2 lands, this is where the migration branches.
|
|
224
237
|
const arr = (parsed as { grants?: unknown }).grants;
|
|
225
238
|
if (!Array.isArray(arr)) return [];
|
|
226
239
|
// Lenient: drop a malformed row rather than failing the whole read (mirrors
|
|
@@ -245,7 +258,7 @@ export function readGrants(storePath: string): GrantRecord[] {
|
|
|
245
258
|
|
|
246
259
|
function writeAll(storePath: string, records: GrantRecord[]): void {
|
|
247
260
|
mkdirSync(dirname(storePath), { recursive: true });
|
|
248
|
-
const file: GrantsFile = { grants: records };
|
|
261
|
+
const file: GrantsFile = { version: GRANTS_FILE_VERSION, grants: records };
|
|
249
262
|
// 0600 — UNLIKE connections.json, this file holds the granted secrets
|
|
250
263
|
// (minted vault tokens + pasted service creds in `material`). `writeFileSync`'s
|
|
251
264
|
// `mode` applies at CREATE time (passed to open(O_CREAT)), so a fresh file is
|
package/src/help.ts
CHANGED
|
@@ -103,8 +103,8 @@ Flags:
|
|
|
103
103
|
Environment:
|
|
104
104
|
PARACHUTE_INSTALL_CHANNEL=rc|latest
|
|
105
105
|
cluster-wide default channel. Lets a Render deploy
|
|
106
|
-
running the hub at \`@rc\` cascade rc to vault /
|
|
107
|
-
scribe
|
|
106
|
+
running the hub at \`@rc\` cascade rc to vault / surface /
|
|
107
|
+
scribe installed via the admin SPA — without
|
|
108
108
|
an explicit \`--channel\` per call. Loses to \`--channel\`
|
|
109
109
|
and \`--tag\`. Defaults to \`latest\` when unset.
|
|
110
110
|
|
|
@@ -144,7 +144,7 @@ What it does:
|
|
|
144
144
|
Fresh-install front door, one command for both laptops AND remote
|
|
145
145
|
servers (EC2, DigitalOcean, Hetzner, any VPS). The admin SPA already
|
|
146
146
|
walks operators through the rest (install vault, set up the admin
|
|
147
|
-
user, install scribe /
|
|
147
|
+
user, install scribe / surface); this command's only job is to
|
|
148
148
|
get you to that wizard.
|
|
149
149
|
|
|
150
150
|
Idempotent — every re-run is safe:
|