@openparachute/hub 0.5.13-rc.47 → 0.5.13-rc.48

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.47",
3
+ "version": "0.5.13-rc.48",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -510,17 +510,26 @@ describe("hubFetch routing", () => {
510
510
  }
511
511
  });
512
512
 
513
- test("malformed services.json returns 500 + CORS, not a crash", async () => {
513
+ test("malformed services.json yields empty doc (lenient read) + CORS, not a crash (hub#406)", async () => {
514
+ // Pre-#406 behavior: strict readManifest threw → /.well-known/parachute.json
515
+ // returned 500. That cascaded into broken discovery for operators who
516
+ // had any kind of services.json corruption.
517
+ //
518
+ // Post-#406: readManifestLenient catches the parse error + logs +
519
+ // returns {services: []}. Well-known builds successfully with an empty
520
+ // services list, so discovery clients get a valid (empty) doc and the
521
+ // operator sees "no services here" rather than a generic 500.
514
522
  const h = makeHarness();
515
523
  try {
516
524
  writeFileSync(h.manifestPath, "{ not json");
517
525
  const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
518
526
  req("/.well-known/parachute.json"),
519
527
  );
520
- expect(res.status).toBe(500);
528
+ expect(res.status).toBe(200);
521
529
  expect(res.headers.get("access-control-allow-origin")).toBe("*");
522
- const body = (await res.json()) as { error: string };
523
- expect(body.error).toContain("well-known build failed");
530
+ const body = (await res.json()) as { vaults: unknown[]; services: unknown[] };
531
+ expect(body.vaults).toEqual([]);
532
+ expect(body.services).toEqual([]);
524
533
  } finally {
525
534
  h.cleanup();
526
535
  }
@@ -50,7 +50,7 @@
50
50
  import type { Database } from "bun:sqlite";
51
51
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
52
52
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
53
- import { findService, readManifest } from "./services-manifest.ts";
53
+ import { findService, readManifest, readManifestLenient } from "./services-manifest.ts";
54
54
  import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
55
55
 
56
56
  /** Scope required to call POST /vaults. */
@@ -172,7 +172,8 @@ function findExistingVault(
172
172
  ): { url: string; version: string; path: string } | null {
173
173
  let manifest: ReturnType<typeof readManifest>;
174
174
  try {
175
- manifest = readManifest(manifestPath);
175
+ // Lenient read — see hub#406.
176
+ manifest = readManifestLenient(manifestPath);
176
177
  } catch {
177
178
  return null;
178
179
  }
@@ -48,7 +48,7 @@ import type { Database } from "bun:sqlite";
48
48
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
49
49
  import { signAccessToken, validateAccessToken } from "./jwt-sign.ts";
50
50
  import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
51
- import { readManifest } from "./services-manifest.ts";
51
+ import { readManifestLenient } from "./services-manifest.ts";
52
52
 
53
53
  /**
54
54
  * Resolve a curated short to its services.json `manifestName` key. Consults
@@ -154,7 +154,8 @@ function resolveUpstream(
154
154
  | { installed: false } {
155
155
  const manifestName = manifestNameForShort(short);
156
156
  if (!manifestName) return { installed: false };
157
- const manifest = readManifest(manifestPath);
157
+ // Lenient see hub#406.
158
+ const manifest = readManifestLenient(manifestPath);
158
159
  const entry = manifest.services.find((s) => s.name === manifestName);
159
160
  if (!entry) return { installed: false };
160
161
  // Mount = the first path the service registers (canonical convention
@@ -40,7 +40,7 @@ import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
40
40
  // still required) and the latter for vault/scribe/runner (post-FALLBACK
41
41
  // retirement, hub#310). The local helper hides the split from the rest of
42
42
  // this file.
43
- import { type UiSubUnit, type UiSubUnitStatus, readManifest } from "./services-manifest.ts";
43
+ import { type UiSubUnit, type UiSubUnitStatus, readManifest, readManifestLenient } from "./services-manifest.ts";
44
44
  import type { ModuleState, Supervisor } from "./supervisor.ts";
45
45
 
46
46
  /**
@@ -303,7 +303,9 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
303
303
  // Load installed state from services.json. Missing file = empty manifest
304
304
  // (fresh container), which is the v0.6 hot path — readManifest already
305
305
  // returns { services: [] } for a missing file, so no extra branching.
306
- const manifest = readManifest(deps.manifestPath);
306
+ // Lenient read so a single bad row written by a buggy module install
307
+ // (e.g. app@0.2.0-rc.4) doesn't take down /api/modules — see hub#406.
308
+ const manifest = readManifestLenient(deps.manifestPath);
307
309
  const installedByShort = new Map<
308
310
  string,
309
311
  {
package/src/hub-server.ts CHANGED
@@ -327,7 +327,8 @@ export function findVaultUpstream(
327
327
  */
328
328
  function hasVaultInstalled(manifestPath: string): boolean {
329
329
  try {
330
- const services = readManifest(manifestPath).services;
330
+ // Lenient see hub#406.
331
+ const services = readManifestLenient(manifestPath).services;
331
332
  return services.some((s) => isVaultEntry(s));
332
333
  } catch {
333
334
  return false;
@@ -499,16 +500,10 @@ async function proxyRequest(
499
500
  * #173 introduced).
500
501
  */
501
502
  async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
502
- let services: readonly ServiceEntry[];
503
- try {
504
- services = readManifest(manifestPath).services;
505
- } catch (err) {
506
- const msg = err instanceof Error ? err.message : String(err);
507
- return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
508
- status: 500,
509
- headers: { "content-type": "application/json" },
510
- });
511
- }
503
+ // Lenient — see hub#406. One bad services.json row no longer takes
504
+ // down vault routing the way it used to take down /admin/setup and
505
+ // /api/modules (the symptom Aaron hit 2026-05-26).
506
+ const services = readManifestLenient(manifestPath).services;
512
507
  const url = new URL(req.url);
513
508
  const match = findVaultUpstream(services, url.pathname);
514
509
  if (!match) return undefined;
@@ -1406,7 +1401,8 @@ export function hubFetch(
1406
1401
  // configured public origin (set by `--issuer https://<fqdn>`), else
1407
1402
  // the request's own origin (fine for direct loopback hits).
1408
1403
  try {
1409
- const manifest = readManifest(manifestPath);
1404
+ // Lenient see hub#406.
1405
+ const manifest = readManifestLenient(manifestPath);
1410
1406
  // Same precedence as the OAuth issuer (hub#298): hub_settings →
1411
1407
  // env → request origin. The well-known doc embeds this origin
1412
1408
  // in service URLs + the issuer metadata link, so it must follow
@@ -1616,7 +1612,8 @@ export function hubFetch(
1616
1612
  // shape the well-known doc derives. Source from services.json so a
1617
1613
  // freshly-created vault is mintable on the next request without a
1618
1614
  // restart.
1619
- const manifest = readManifest(manifestPath);
1615
+ // Lenient see hub#406.
1616
+ const manifest = readManifestLenient(manifestPath);
1620
1617
  const knownVaultNames = new Set<string>();
1621
1618
  for (const s of manifest.services) {
1622
1619
  if (!isVaultEntry(s)) continue;
@@ -70,7 +70,10 @@ import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope
70
70
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
71
71
  import {
72
72
  type ServicesManifest,
73
- readManifest as readServicesManifest,
73
+ // Hot-path OAuth flows use the lenient reader so a single malformed
74
+ // services.json row (e.g. from a buggy module install) doesn't crash
75
+ // the entire OAuth dispatch. See hub#406.
76
+ readManifestLenient as readServicesManifest,
74
77
  } from "./services-manifest.ts";
75
78
  import {
76
79
  SESSION_TTL_MS,
@@ -471,17 +471,21 @@ export function readManifestLenient(
471
471
  }
472
472
  }
473
473
  // Best-effort duplicate-port detection — log + drop the duplicate
474
- // rather than throw.
475
- const seenPorts = new Set<number>();
474
+ // rather than throw. Mirrors the strict `assertNoDuplicatePorts`'s
475
+ // vault-on-vault exception: multiple parachute-vault-<name> rows
476
+ // legitimately share port 1940 because they're all served by the
477
+ // single vault module process.
478
+ const portsSeen = new Map<number, string>();
476
479
  const dedup: ServiceEntry[] = [];
477
480
  for (const e of valid) {
478
- if (seenPorts.has(e.port)) {
481
+ const prev = portsSeen.get(e.port);
482
+ if (prev !== undefined && !(isVaultName(prev) && isVaultName(e.name))) {
479
483
  log.warn?.(
480
- `[services-manifest] dropping duplicate-port entry: name=${JSON.stringify(e.name)} port=${e.port}`,
484
+ `[services-manifest] dropping duplicate-port entry: name=${JSON.stringify(e.name)} port=${e.port} (already claimed by ${JSON.stringify(prev)})`,
481
485
  );
482
486
  continue;
483
487
  }
484
- seenPorts.add(e.port);
488
+ if (prev === undefined) portsSeen.set(e.port, e.name);
485
489
  dedup.push(e);
486
490
  }
487
491
  return { services: dedup };
@@ -65,7 +65,7 @@ import {
65
65
  import { escapeHtml } from "./oauth-ui.ts";
66
66
  import { mintOperatorToken } from "./operator-token.ts";
67
67
  import { isHttpsRequest } from "./request-protocol.ts";
68
- import { findService, readManifest } from "./services-manifest.ts";
68
+ import { findService, readManifestLenient } from "./services-manifest.ts";
69
69
  import {
70
70
  SESSION_TTL_MS,
71
71
  buildSessionCookie,
@@ -1716,7 +1716,7 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
1716
1716
  * (op status snapshot). Pure-ish — only the registry call is impure.
1717
1717
  */
1718
1718
  function buildInstallTiles(url: URL, deps: SetupWizardDeps): ModuleInstallTileState[] {
1719
- const manifest = readManifest(deps.manifestPath);
1719
+ const manifest = readManifestLenient(deps.manifestPath);
1720
1720
  return INSTALL_TILE_PROPS.filter((p) =>
1721
1721
  (CURATED_MODULES as readonly string[]).includes(p.short),
1722
1722
  ).map((p) => {
@@ -1874,7 +1874,7 @@ function validateAccountFields(input: {
1874
1874
  * shared with `buildInstallTiles`.
1875
1875
  */
1876
1876
  function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boolean {
1877
- const manifest = readManifest(manifestPath);
1877
+ const manifest = readManifestLenient(manifestPath);
1878
1878
  const spec = specFor(short);
1879
1879
  return manifest.services.some((s) => s.name === spec.manifestName);
1880
1880
  }
@@ -1885,7 +1885,7 @@ function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boo
1885
1885
  * entry's metadata isn't present.
1886
1886
  */
1887
1887
  function firstVaultName(manifestPath: string): string {
1888
- const manifest = readManifest(manifestPath);
1888
+ const manifest = readManifestLenient(manifestPath);
1889
1889
  // Match on the canonical vault manifestName from the curated spec.
1890
1890
  // (`CURATED_MODULES.includes("vault")` was a dead guard — vault is a
1891
1891
  // tuple-literal member, so the conjunct is always true.)