@openparachute/hub 0.5.13-rc.14 → 0.5.13-rc.21

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.
@@ -78,16 +78,6 @@ export function isCanonicalPort(port: number): boolean {
78
78
  return port >= CANONICAL_PORT_MIN && port <= CANONICAL_PORT_MAX;
79
79
  }
80
80
 
81
- /**
82
- * Broad shape of a service. Matches the hub's card-kind taxonomy.
83
- * "frontend" a user-facing UI (notes). Safe to expose by default.
84
- * "api" a programmatic surface (vault, channel, scribe). Whether
85
- * it's safe to expose depends on `hasAuth`.
86
- * "tool" like "api" but specifically MCP-shaped / agent-callable.
87
- * Treated the same as "api" for exposure defaults.
88
- */
89
- export type ServiceKind = "api" | "tool" | "frontend";
90
-
91
81
  /**
92
82
  * Imperative behaviors that don't fit the static `module.json` schema.
93
83
  *
@@ -174,14 +164,6 @@ export interface ServiceSpec {
174
164
  * First service boot overwrites the seed with its own authoritative version.
175
165
  */
176
166
  readonly seedEntry?: () => ServiceEntry;
177
- /**
178
- * Optional as of hub#327 (Phase A's fold): the validator no longer
179
- * inspects `kind`, so synthesized + third-party-manifest specs may
180
- * carry `undefined` here. The single read site
181
- * (`commands/upgrade.ts: target.spec?.kind === "frontend"`) handles
182
- * the absent case via the `=== "frontend"` falsy-fallthrough.
183
- */
184
- readonly kind?: ServiceKind;
185
167
  readonly hasAuth?: boolean;
186
168
  readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
187
169
  readonly postInstallFooter?: () => readonly string[];
@@ -239,7 +221,6 @@ export function composeServiceSpec(opts: {
239
221
  package: packageName,
240
222
  manifestName: manifest.manifestName,
241
223
  seedEntry: () => seedEntryFromManifest(manifest),
242
- kind: manifest.kind,
243
224
  };
244
225
  if (extras?.init !== undefined) (spec as { init?: readonly string[] }).init = extras.init;
245
226
  if (startCmd !== undefined) {
@@ -301,7 +282,6 @@ const NOTES_FALLBACK: FirstPartyFallback = {
301
282
  manifestName: "parachute-notes",
302
283
  displayName: "Notes",
303
284
  tagline: "Notes PWA — daemon deprecated 2026-05-22; install `app` for the current path.",
304
- kind: "frontend",
305
285
  port: 1942,
306
286
  paths: ["/notes"],
307
287
  health: "/notes/health",
@@ -331,7 +311,6 @@ const CHANNEL_FALLBACK: FirstPartyFallback = {
331
311
  manifestName: "parachute-channel",
332
312
  displayName: "Channel",
333
313
  tagline: "Notification fan-out across modules.",
334
- kind: "api",
335
314
  port: 1941,
336
315
  paths: ["/channel"],
337
316
  health: "/channel/health",
@@ -397,9 +376,6 @@ export interface KnownModule {
397
376
  /** Pre-install catalog surfaces use these. After install, services.json wins. */
398
377
  readonly displayName: string;
399
378
  readonly tagline: string;
400
- /** Module kind — needed for `synthesizeManifest` since KNOWN_MODULES doesn't
401
- * carry an embedded manifest. All three current entries are api/tool. */
402
- readonly kind: ModuleKind;
403
379
  /** Canonical mount paths — used to synthesize a minimal manifest when
404
380
  * module.json is unreadable (legacy install paths, test fixtures). The
405
381
  * module's own `module.json` overrides these once it's installed. */
@@ -412,12 +388,6 @@ export interface KnownModule {
412
388
  readonly extras?: FirstPartyExtras;
413
389
  }
414
390
 
415
- // Local import-time alias for ModuleKind so the public KnownModule type can
416
- // reference it without forcing every consumer to import from
417
- // module-manifest. The same `ModuleKind` type is exported from there for
418
- // callers that want it directly.
419
- type ModuleKind = ModuleManifest["kind"];
420
-
421
391
  export const KNOWN_MODULES: Record<string, KnownModule> = {
422
392
  vault: {
423
393
  short: "vault",
@@ -426,7 +396,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
426
396
  canonicalPort: 1940,
427
397
  displayName: "Vault",
428
398
  tagline: "Your owner-authenticated MCP knowledge store.",
429
- kind: "api",
430
399
  canonicalPaths: ["/vault/default"],
431
400
  canonicalHealth: "/vault/default/health",
432
401
  extras: {
@@ -446,7 +415,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
446
415
  canonicalPort: 1943,
447
416
  displayName: "Scribe",
448
417
  tagline: "Local audio transcription for vault recordings.",
449
- kind: "api",
450
418
  canonicalPaths: ["/scribe"],
451
419
  canonicalHealth: "/scribe/health",
452
420
  canonicalStripPrefix: true,
@@ -481,7 +449,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
481
449
  displayName: "Runner",
482
450
  tagline:
483
451
  "Vault-as-job-substrate engine — spawns claude -p against vault job notes on schedule.",
484
- kind: "tool",
485
452
  canonicalPaths: ["/runner", "/.parachute"],
486
453
  canonicalHealth: "/runner/healthz",
487
454
  canonicalStripPrefix: false,
@@ -507,10 +474,6 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
507
474
  // still exists as a back-compat install (CURATED_MODULES still lists
508
475
  // `notes`) but `app` is the recommended first install post-vault.
509
476
  tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
510
- // Frontend posture: app's primary surface is serving sub-app UIs under
511
- // `/app/<name>/` mounted under one origin (design doc §12). Hub's
512
- // exposure-default + supervisor-defaults follow the frontend lane.
513
- kind: "frontend",
514
477
  canonicalPaths: ["/app", "/.parachute"],
515
478
  canonicalHealth: "/app/healthz",
516
479
  canonicalStripPrefix: false,
@@ -528,6 +491,35 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
528
491
  },
529
492
  };
530
493
 
494
+ /**
495
+ * Modules that were once first-party (committed-core or FIRST_PARTY_FALLBACKS)
496
+ * but have since been retired. Services.json rows under these names are
497
+ * GC'd on load with a stderr warning.
498
+ *
499
+ * Adding a name here is a deliberate retirement signal — operators who
500
+ * still have the module's daemon running will see the warning + a hint
501
+ * to stop the daemon. The row reappears if they restart the daemon
502
+ * (which still self-registers under its old name), but the GC ensures
503
+ * routing isn't blocked by a stale row.
504
+ *
505
+ * Curation rules:
506
+ * - Only add an entry when the module is *explicitly* retired (see
507
+ * `parachute-patterns/migrations/` + per-module DEPRECATED.md). Don't
508
+ * speculate on Phase-2-deprecating modules — they're still serving
509
+ * back-compat traffic and adding them here would prematurely break
510
+ * legacy operators. `notes` (the daemon) is the canonical
511
+ * "deprecating-but-not-retired" case as of 2026-05-22: do not add
512
+ * until its Phase 3 retirement lands.
513
+ * - Entries stay forever. Removing an entry would let a stale row
514
+ * reappear silently on legacy installs.
515
+ */
516
+ export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
517
+ agent: {
518
+ retiredAt: "2026-05-20",
519
+ replacement: "parachute-app or parachute-runner (depending on use case)",
520
+ },
521
+ };
522
+
531
523
  /**
532
524
  * Synthesize a minimal `ModuleManifest` from a KNOWN_MODULES entry. Used as
533
525
  * a fallback when `<installDir>/.parachute/module.json` can't be read
@@ -547,7 +539,6 @@ export function synthesizeManifestForKnownModule(km: KnownModule): ModuleManifes
547
539
  manifestName: km.manifestName,
548
540
  displayName: km.displayName,
549
541
  tagline: km.tagline,
550
- kind: km.kind,
551
542
  port: km.canonicalPort,
552
543
  paths: km.canonicalPaths,
553
544
  health: km.canonicalHealth,
@@ -560,10 +551,10 @@ export function synthesizeManifestForKnownModule(km: KnownModule): ModuleManifes
560
551
 
561
552
  /**
562
553
  * Effective publicExposure for a service, given what's on its services.json
563
- * entry. Explicit wins. If absent, derive from the spec: known api/tool
564
- * services without declared auth fall back to "auth-required"; everything
565
- * else defaults to "allowed" so vault, notes, channel and unknown
566
- * third-party services continue to be exposed without needing to opt in.
554
+ * entry. Explicit wins. If absent, derive from the spec: services with
555
+ * declared auth (extras.hasAuth === true, or no hasAuth set) default to
556
+ * "allowed"; services with extras.hasAuth === false default to
557
+ * "auth-required". Unknown third-party services default to "allowed".
567
558
  *
568
559
  * Layer behavior (post-#187 layer-aware proxy):
569
560
  * "allowed" — reaches all layers (loopback / tailnet / public);
@@ -582,30 +573,17 @@ export function effectivePublicExposure(
582
573
  if (entry.publicExposure !== undefined) return entry.publicExposure;
583
574
  const short = shortNameForManifest(entry.name);
584
575
  if (short === undefined) return "allowed";
585
- // FALLBACK path: notes / channel still vendor their kind + extras.
576
+ // Post hub#301 Phase C/D (`kind` field retired hub#330), the
577
+ // exposure-default heuristic collapses to the imperative `extras.hasAuth`
578
+ // signal: an explicit `hasAuth: false` declaration ("no auth gate
579
+ // implemented yet") → require auth before exposing; anything else →
580
+ // allowed. Scribe is the canonical `hasAuth: false` case today.
586
581
  const fb = FIRST_PARTY_FALLBACKS[short];
587
582
  if (fb) {
588
- if (
589
- (fb.manifest.kind === "api" || fb.manifest.kind === "tool") &&
590
- fb.extras?.hasAuth === false
591
- ) {
592
- return "auth-required";
593
- }
594
- return "allowed";
583
+ return fb.extras?.hasAuth === false ? "auth-required" : "allowed";
595
584
  }
596
- // KNOWN_MODULES path: vault / scribe / runner. The `kind` lives on
597
- // services.json (operator-authoritative after self-register) — if the row
598
- // doesn't declare it, fall through to the imperative `extras.hasAuth`
599
- // signal which says "no auth gate → require auth before exposing." This
600
- // matches the pre-retirement FALLBACK behavior for scribe.
601
585
  const km = KNOWN_MODULES[short];
602
- if (km && km.extras?.hasAuth === false) {
603
- // Only api/tool services hit the "auth-required default" lane —
604
- // services.json's `kind` is the authoritative source; absent it,
605
- // KNOWN_MODULES doesn't carry `kind` so we assume an api/tool posture
606
- // (the three KNOWN_MODULES entries are all api/tool today).
607
- return "auth-required";
608
- }
586
+ if (km && km.extras?.hasAuth === false) return "auth-required";
609
587
  return "allowed";
610
588
  }
611
589
 
@@ -650,7 +628,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
650
628
  *
651
629
  * KNOWN_MODULES shorts (vault / scribe / runner — post hub#310 FALLBACK
652
630
  * retirement) return a **minimal** spec carrying `package`, `manifestName`,
653
- * `kind` (best-effort api/tool), and the imperative `extras` fields
631
+ * and the imperative `extras` fields
654
632
  * (`init`, `hasAuth`, `urlForEntry`, `postInstallFooter`). They do NOT carry
655
633
  * `startCmd` or `seedEntry` — those come from `<installDir>/.parachute/module.json`
656
634
  * at lifecycle time via {@link getSpecFromInstallDir}, since the module
@@ -670,14 +648,13 @@ export function getSpec(short: string): ServiceSpec | undefined {
670
648
  const km = KNOWN_MODULES[short];
671
649
  if (!km) return undefined;
672
650
  // Use the synthesized manifest from KNOWN_MODULES' canonical fields so
673
- // downstream consumers (seedEntry, kind-aware exposure, port assignment)
674
- // see a coherent spec. Module.json wins at lifecycle time
675
- // (`composeKnownModuleSpec`); this synth is the bootstrap shape.
651
+ // downstream consumers (seedEntry, port assignment) see a coherent spec.
652
+ // Module.json wins at lifecycle time (`composeKnownModuleSpec`); this
653
+ // synth is the bootstrap shape.
676
654
  const synthManifest = synthesizeManifestForKnownModule(km);
677
655
  const spec: ServiceSpec = {
678
656
  package: km.package,
679
657
  manifestName: km.manifestName,
680
- kind: synthManifest.kind,
681
658
  seedEntry: () => seedEntryFromManifest(synthManifest),
682
659
  };
683
660
  if (km.extras?.hasAuth !== undefined) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
4
+ import { RETIRED_MODULES } from "./service-spec.ts";
4
5
 
5
6
  /**
6
7
  * Whether the service is safe to mount on public-facing expose layers.
@@ -17,7 +18,7 @@ import { SERVICES_MANIFEST_PATH } from "./config.ts";
17
18
  * auth state over `/.parachute/info`.
18
19
  *
19
20
  * Absent field: the CLI derives a safe default from the service's ServiceSpec
20
- * (known api/tool services without declared auth → "auth-required"; everything
21
+ * (services with extras.hasAuth === false → "auth-required"; everything
21
22
  * else → "allowed"). Unknown services default to "allowed" for back-compat.
22
23
  */
23
24
  export type PublicExposure = "allowed" | "loopback" | "auth-required";
@@ -376,12 +377,171 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
376
377
  `failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
377
378
  );
378
379
  }
379
- const validated = validateManifest(raw, path);
380
+ // Retired-module + legacy short-name row cleanup runs BEFORE shape
381
+ // validation because the bugs they heal (rows that conflict on port with
382
+ // a current row) would otherwise throw inside `validateManifest`'s
383
+ // duplicate-port gate. Order matters: drop retired-module rows FIRST so
384
+ // their absence can unmask a `parachute-<X>` ↔ `<X>` pair underneath that
385
+ // `dropLegacyShortNameRows` then handles. The reverse order would leave
386
+ // a retired row that the short-name pass doesn't recognize. Both passes
387
+ // mutate the raw JSON object's `services` array; we re-validate against
388
+ // the cleaned shape. See `dropRetiredModuleRows` + `dropLegacyShortNameRows`
389
+ // for the full discipline.
390
+ const afterRetired = dropRetiredModuleRows(raw, path);
391
+ const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
392
+ const validated = validateManifest(cleaned.raw, path);
380
393
  const migrated = migrateClawToAgent(validated);
381
- if (migrated.changed) writeManifest(migrated.manifest, path);
394
+ const changed = afterRetired.changed || cleaned.changed || migrated.changed;
395
+ if (changed) writeManifest(migrated.manifest, path);
382
396
  return migrated.manifest;
383
397
  }
384
398
 
399
+ /**
400
+ * Drop rows whose `name` matches a registered retired module (see
401
+ * `RETIRED_MODULES` in `service-spec.ts`). Retirement is unconditional —
402
+ * unlike `dropLegacyShortNameRows`, which only fires when a same-port
403
+ * manifestName twin is present, this pass drops a retired row whether or
404
+ * not anything else collides with it. The row is stale by definition: the
405
+ * module's npm package and daemon are no longer first-party, and leaving
406
+ * the row in place either (a) routes operators to a no-longer-supported
407
+ * binary or (b) blocks a current module from claiming the port the retired
408
+ * row squats on (Aaron's 2026-05-22 reproducer: stale `agent` row at 1946
409
+ * collided with `parachute-app` self-registering at 1946).
410
+ *
411
+ * Per-row stderr warning is operator-actionable: cites the retirement date,
412
+ * names the replacement module (if any), and includes a one-line shell
413
+ * snippet for stopping the still-running daemon process. The row reappears
414
+ * on the next read if the daemon is still up (its self-register writes
415
+ * back the same name), so the warning steers operators to fully retire
416
+ * the legacy process, not just hand-edit the file.
417
+ *
418
+ * Operates on raw JSON rather than validated entries so a retired row that
419
+ * shares a port with a current row doesn't trip `validateManifest`'s
420
+ * duplicate-port gate before this helper runs. Closes hub#334.
421
+ */
422
+ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
423
+ if (!raw || typeof raw !== "object") return { raw, changed: false };
424
+ const services = (raw as Record<string, unknown>).services;
425
+ if (!Array.isArray(services)) return { raw, changed: false };
426
+
427
+ type Dropped = { name: string; retiredAt: string; replacement?: string };
428
+ const dropped: Dropped[] = [];
429
+ const nextServices = services.filter((row) => {
430
+ if (!row || typeof row !== "object") return true;
431
+ const name = (row as Record<string, unknown>).name;
432
+ if (typeof name !== "string") return true;
433
+ const retired = RETIRED_MODULES[name];
434
+ if (retired === undefined) return true;
435
+ dropped.push({ name, retiredAt: retired.retiredAt, replacement: retired.replacement });
436
+ return false;
437
+ });
438
+
439
+ if (dropped.length === 0) return { raw, changed: false };
440
+
441
+ for (const d of dropped) {
442
+ const replacementLine = d.replacement ? ` Replacement: ${d.replacement}.` : "";
443
+ console.error(
444
+ `${where}: dropped stale row for retired module '${d.name}' (retired ${d.retiredAt}).${replacementLine} If the ${d.name} daemon is still running, stop it (e.g. \`ps aux | grep ${d.name}\` then \`kill <pid>\`).`,
445
+ );
446
+ }
447
+
448
+ return {
449
+ raw: { ...(raw as Record<string, unknown>), services: nextServices },
450
+ changed: true,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Drop legacy short-name rows when a same-module manifestName row exists
456
+ * on the same port. Handles the duplicate-row class introduced when a
457
+ * module's self-register wrote `name: "<short>"` (e.g. `"app"`) while
458
+ * hub's install path stamped `name: "parachute-<short>"` (the canonical
459
+ * manifestName key). The two rows shared a port and tripped the
460
+ * duplicate-port read gate, leaving operators with an unbootable
461
+ * services.json until they edited by hand. Aaron hit this on 2026-05-22
462
+ * with `parachute-app` + `app` after parachute-app#13 + parachute-runner#4
463
+ * had already fixed the upstream self-register writes; this gate cleans
464
+ * up legacy state on operators who had already booted the bad code path.
465
+ *
466
+ * Detection is structural — no module registry import needed:
467
+ * - two rows share a port (NOT the multi-vault carve-out)
468
+ * - one row's name matches `parachute-<X>`
469
+ * - the other row's name matches `<X>` (same `<X>`)
470
+ *
471
+ * When that shape is present, drop the short-named row + log a warning
472
+ * to stderr so operators see the auto-cleanup the next time they read
473
+ * services.json (via any CLI command that touches the file). The
474
+ * manifestName row wins — it carries the hub-stamped fields (installDir,
475
+ * version after self-register's first overwrite) operators care about.
476
+ *
477
+ * Returns the (possibly mutated) raw object + a `changed` flag so the
478
+ * caller can re-write services.json with the cleaned shape. Operates on
479
+ * raw JSON rather than validated entries because the duplicate-port row
480
+ * pair would otherwise throw during validation before this helper had a
481
+ * chance to run.
482
+ */
483
+ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
484
+ if (!raw || typeof raw !== "object") return { raw, changed: false };
485
+ const services = (raw as Record<string, unknown>).services;
486
+ if (!Array.isArray(services)) return { raw, changed: false };
487
+
488
+ // Build a port → [rows] index to spot same-port pairs. We only care
489
+ // about rows that parse to the minimal `{ name, port }` shape — anything
490
+ // else fails downstream validation anyway and isn't part of the bug
491
+ // class.
492
+ type Probe = { name: string; port: number; index: number };
493
+ const byPort = new Map<number, Probe[]>();
494
+ services.forEach((row, index) => {
495
+ if (!row || typeof row !== "object") return;
496
+ const name = (row as Record<string, unknown>).name;
497
+ const port = (row as Record<string, unknown>).port;
498
+ if (typeof name !== "string" || typeof port !== "number") return;
499
+ const bucket = byPort.get(port) ?? [];
500
+ bucket.push({ name, port, index });
501
+ byPort.set(port, bucket);
502
+ });
503
+
504
+ const dropIndices = new Set<number>();
505
+ for (const bucket of byPort.values()) {
506
+ if (bucket.length < 2) continue;
507
+ for (const a of bucket) {
508
+ for (const b of bucket) {
509
+ if (a.index === b.index) continue;
510
+ // a is the "short" candidate, b is the "manifestName" candidate.
511
+ // The shape rule: b.name === `parachute-${a.name}` and a is not
512
+ // itself a `parachute-…` name. Both halves matter: without the
513
+ // second check, `parachute-vault` paired with `parachute-parachute-vault`
514
+ // would drop the real vault row (no such pair exists today, but
515
+ // the shape rule keeps the heuristic narrow on principle).
516
+ if (a.name.startsWith("parachute-")) continue;
517
+ if (b.name !== `parachute-${a.name}`) continue;
518
+ dropIndices.add(a.index);
519
+ }
520
+ }
521
+ }
522
+
523
+ if (dropIndices.size === 0) return { raw, changed: false };
524
+
525
+ const dropped: string[] = [];
526
+ const nextServices = services.filter((row, index) => {
527
+ if (!dropIndices.has(index)) return true;
528
+ if (row && typeof row === "object") {
529
+ const name = (row as Record<string, unknown>).name;
530
+ if (typeof name === "string") dropped.push(name);
531
+ }
532
+ return false;
533
+ });
534
+
535
+ console.error(
536
+ `${where}: dropped legacy short-name row(s) [${dropped.join(", ")}] in favor of same-port manifestName row(s). This is the parachute-app#13 / parachute-runner#4 self-register fixup. See parachute-patterns/patterns/services-json-row-conventions.md.`,
537
+ );
538
+
539
+ return {
540
+ raw: { ...(raw as Record<string, unknown>), services: nextServices },
541
+ changed: true,
542
+ };
543
+ }
544
+
385
545
  /**
386
546
  * Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
387
547
  * to parachute-agent across the ecosystem (npm package, mount path, short