@openparachute/hub 0.5.13-rc.39 → 0.5.13-rc.40

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.39",
3
+ "version": "0.5.13-rc.40",
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": {
@@ -519,7 +519,7 @@ describe("GET /api/modules", () => {
519
519
  techne: {
520
520
  displayName: "Techne",
521
521
  path: "/vault/techne",
522
- status: "pending-oauth",
522
+ status: "pending",
523
523
  },
524
524
  },
525
525
  },
@@ -566,7 +566,7 @@ describe("GET /api/modules", () => {
566
566
  icon_url: null,
567
567
  version: null,
568
568
  oauth_client_id: null,
569
- status: "pending-oauth",
569
+ status: "pending",
570
570
  },
571
571
  ]);
572
572
  // Other curated rows stay empty — uis is per-row, not global.
@@ -590,7 +590,7 @@ describe("GET /api/modules", () => {
590
590
  iconUrl: "/vault/full/icon.svg",
591
591
  version: "0.3.1",
592
592
  oauthClientId: "c1",
593
- status: "disabled",
593
+ status: "inactive",
594
594
  },
595
595
  },
596
596
  },
@@ -626,9 +626,61 @@ describe("GET /api/modules", () => {
626
626
  icon_url: "/vault/full/icon.svg",
627
627
  version: "0.3.1",
628
628
  oauth_client_id: "c1",
629
- status: "disabled",
629
+ status: "inactive",
630
630
  });
631
631
  });
632
+
633
+ test("legacy `pending-oauth` / `disabled` status values normalize to canonical vocab on the wire (workstream F back-compat)", async () => {
634
+ // Workstream F unifies the SPA / CLI / well-known state vocab onto
635
+ // `active | pending | inactive | failing`. Old modules / SDKs may
636
+ // still write the pre-F values to services.json (`pending-oauth`,
637
+ // `disabled`). `services-manifest.ts` normalizes on read so every
638
+ // downstream emit (this API, well-known doc) sees the canonical
639
+ // form. Pins that boundary normalization end-to-end here.
640
+ writeManifest(h.manifestPath, [
641
+ {
642
+ name: "parachute-vault",
643
+ port: 1940,
644
+ paths: ["/vault/default"],
645
+ health: "/vault/default/health",
646
+ version: "0.4.5",
647
+ uis: {
648
+ legacy_pending: {
649
+ displayName: "Legacy Pending",
650
+ path: "/vault/legacy-pending",
651
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately
652
+ // writing the pre-F legacy alias to pin the normalization
653
+ // boundary; the schema accepts it on read.
654
+ status: "pending-oauth" as any,
655
+ },
656
+ legacy_disabled: {
657
+ displayName: "Legacy Disabled",
658
+ path: "/vault/legacy-disabled",
659
+ // biome-ignore lint/suspicious/noExplicitAny: same as above.
660
+ status: "disabled" as any,
661
+ },
662
+ },
663
+ },
664
+ ]);
665
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
666
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
667
+ db: h.db,
668
+ issuer: ISSUER,
669
+ manifestPath: h.manifestPath,
670
+ fetchLatestVersion: async () => null,
671
+ });
672
+ const body = (await res.json()) as {
673
+ modules: Array<{
674
+ short: string;
675
+ uis: Array<{ name: string; status: string | null }>;
676
+ }>;
677
+ };
678
+ const vault = body.modules.find((m) => m.short === "vault");
679
+ const pending = vault?.uis.find((u) => u.name === "legacy_pending");
680
+ const inactive = vault?.uis.find((u) => u.name === "legacy_disabled");
681
+ expect(pending?.status).toBe("pending");
682
+ expect(inactive?.status).toBe("inactive");
683
+ });
632
684
  });
633
685
  });
634
686
 
@@ -48,6 +48,42 @@ describe("buildHubBoundOrigins", () => {
48
48
  expect(origins.filter((o) => o === ISSUER).length).toBe(1);
49
49
  });
50
50
 
51
+ test("platformOrigin adds the platform-injected public URL independently of issuer (hub#375)", () => {
52
+ // Render injects RENDER_EXTERNAL_URL=https://<svc>.onrender.com at the
53
+ // container edge; if hub_settings.hub_origin was stored to a non-public
54
+ // URL (e.g. loopback during initial setup), the configured issuer would
55
+ // be loopback. The browser still POSTs from the public Render URL, so
56
+ // the public URL must independently land in the bound set or the
57
+ // operator's legitimate POSTs are rejected. Closes the failure caught
58
+ // on Aaron's deploy 2026-05-25 where Origin was https://...onrender.com
59
+ // but bound set was loopback-only.
60
+ const platformOrigin = "https://parachute-hub.onrender.com";
61
+ const origins = buildHubBoundOrigins({
62
+ issuer: "http://127.0.0.1:1939",
63
+ loopbackPort: PORT,
64
+ platformOrigin,
65
+ });
66
+ expect(origins).toContain(platformOrigin);
67
+ expect(origins).toContain("http://127.0.0.1:1939");
68
+ });
69
+
70
+ test("platformOrigin dedups when it matches issuer", () => {
71
+ // Normal Render boot path: configuredIssuer was derived from
72
+ // RENDER_EXTERNAL_URL in serve.ts's resolveStartupIssuer, so the
73
+ // resolved issuer equals platformOrigin. The set carries one entry.
74
+ const platformOrigin = "https://parachute-hub.onrender.com";
75
+ const origins = buildHubBoundOrigins({
76
+ issuer: platformOrigin,
77
+ platformOrigin,
78
+ });
79
+ expect(origins.filter((o) => o === platformOrigin).length).toBe(1);
80
+ });
81
+
82
+ test("undefined platformOrigin is a no-op (non-Render deploys)", () => {
83
+ const origins = buildHubBoundOrigins({ issuer: ISSUER });
84
+ expect(origins).toEqual([ISSUER]);
85
+ });
86
+
51
87
  test("malformed inputs are silently dropped", () => {
52
88
  // No URL parser crash — return whatever could be parsed. The caller
53
89
  // (resolveBoundOrigins) keeps the issuer as a baseline anyway.
@@ -257,7 +257,7 @@ describe("services-manifest", () => {
257
257
  displayName: "Unforced Brain",
258
258
  path: "/app/unforced-brain",
259
259
  oauthClientId: "client_def456",
260
- status: "pending-oauth",
260
+ status: "pending",
261
261
  },
262
262
  },
263
263
  };
@@ -386,6 +386,79 @@ describe("services-manifest", () => {
386
386
  }
387
387
  });
388
388
 
389
+ test("normalizes legacy `pending-oauth` → `pending` on read (workstream F back-compat)", () => {
390
+ // Pre-F services may still write the legacy alias. The schema
391
+ // accepts it on read + normalizes to the canonical vocab so
392
+ // downstream emit surfaces (well-known, /api/modules, SPA) always
393
+ // see the canonical form. Retire after the next rc-chain alias
394
+ // window per design-system.md §6.
395
+ const { path, cleanup } = makeTempPath();
396
+ try {
397
+ const legacy: ServiceEntry = {
398
+ ...app,
399
+ uis: {
400
+ slug: {
401
+ displayName: "S",
402
+ path: "/app/s",
403
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately
404
+ // writing the pre-F legacy alias to pin the normalization
405
+ // boundary; the schema accepts it on read.
406
+ status: "pending-oauth" as any,
407
+ },
408
+ },
409
+ };
410
+ upsertService(legacy, path);
411
+ const got = readManifest(path).services[0]?.uis?.slug;
412
+ expect(got?.status).toBe("pending");
413
+ } finally {
414
+ cleanup();
415
+ }
416
+ });
417
+
418
+ test("normalizes legacy `disabled` → `inactive` on read (workstream F back-compat)", () => {
419
+ const { path, cleanup } = makeTempPath();
420
+ try {
421
+ const legacy: ServiceEntry = {
422
+ ...app,
423
+ uis: {
424
+ slug: {
425
+ displayName: "S",
426
+ path: "/app/s",
427
+ // biome-ignore lint/suspicious/noExplicitAny: same as above.
428
+ status: "disabled" as any,
429
+ },
430
+ },
431
+ };
432
+ upsertService(legacy, path);
433
+ const got = readManifest(path).services[0]?.uis?.slug;
434
+ expect(got?.status).toBe("inactive");
435
+ } finally {
436
+ cleanup();
437
+ }
438
+ });
439
+
440
+ test("accepts new canonical states (`failing`, `inactive`)", () => {
441
+ // `failing` is new in workstream F (no pre-F equivalent — pre-F
442
+ // collapsed failing into `disabled`). `inactive` is the new
443
+ // canonical name for `disabled`. Both must validate.
444
+ const { path, cleanup } = makeTempPath();
445
+ try {
446
+ const entry: ServiceEntry = {
447
+ ...app,
448
+ uis: {
449
+ f: { displayName: "F", path: "/app/f", status: "failing" },
450
+ i: { displayName: "I", path: "/app/i", status: "inactive" },
451
+ },
452
+ };
453
+ upsertService(entry, path);
454
+ const got = readManifest(path).services[0]?.uis;
455
+ expect(got?.f?.status).toBe("failing");
456
+ expect(got?.i?.status).toBe("inactive");
457
+ } finally {
458
+ cleanup();
459
+ }
460
+ });
461
+
389
462
  test("rejects non-string oauthClientId", () => {
390
463
  const { path, cleanup } = makeTempPath();
391
464
  try {
@@ -62,15 +62,21 @@ describe("status", () => {
62
62
  expect(code).toBe(0);
63
63
  expect(seen).toContain("http://localhost:1940/health");
64
64
  expect(seen).toContain("http://localhost:3200/scribe/health");
65
+ // Header reflects the post-workstream-F column shape:
66
+ // SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
65
67
  expect(lines[0]).toMatch(/SERVICE/);
68
+ expect(lines[0]).toMatch(/STATE/);
69
+ expect(lines[0]).not.toMatch(/PROCESS/);
70
+ expect(lines[0]).not.toMatch(/HEALTH/);
66
71
  expect(lines.some((l) => l.includes("parachute-vault"))).toBe(true);
67
- expect(lines.some((l) => l.includes("ok"))).toBe(true);
72
+ // Healthy probe rolls up to `active` per design-system.md §6.
73
+ expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
68
74
  } finally {
69
75
  cleanup();
70
76
  }
71
77
  });
72
78
 
73
- test("any-failing returns 1", async () => {
79
+ test("any-failing returns 1 and surfaces probe detail on continuation line", async () => {
74
80
  const { path, cleanup } = makeTempPath();
75
81
  try {
76
82
  upsertService(
@@ -86,13 +92,17 @@ describe("status", () => {
86
92
  print: (l) => lines.push(l),
87
93
  });
88
94
  expect(code).toBe(1);
89
- expect(lines.some((l) => l.includes("ECONNREFUSED"))).toBe(true);
95
+ // STATE column rolls up to `failing`.
96
+ expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(true);
97
+ // The pre-F HEALTH column's detail survives on a continuation line
98
+ // (" ! probe: ECONNREFUSED") so the operator can still diagnose.
99
+ expect(lines.some((l) => l.includes("probe:") && l.includes("ECONNREFUSED"))).toBe(true);
90
100
  } finally {
91
101
  cleanup();
92
102
  }
93
103
  });
94
104
 
95
- test("http non-2xx counts as unhealthy with status code", async () => {
105
+ test("http non-2xx counts as failing and surfaces the code on the probe line", async () => {
96
106
  const { path, cleanup } = makeTempPath();
97
107
  try {
98
108
  upsertService(
@@ -106,13 +116,14 @@ describe("status", () => {
106
116
  print: (l) => lines.push(l),
107
117
  });
108
118
  expect(code).toBe(1);
109
- expect(lines.some((l) => l.includes("http 503"))).toBe(true);
119
+ expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(true);
120
+ expect(lines.some((l) => l.includes("probe: http 503"))).toBe(true);
110
121
  } finally {
111
122
  cleanup();
112
123
  }
113
124
  });
114
125
 
115
- test("running process shows pid + uptime and still probes", async () => {
126
+ test("running + healthy probe shows STATE=active, pid + uptime", async () => {
116
127
  const { path, configDir, cleanup } = makeTempPath();
117
128
  try {
118
129
  upsertService(
@@ -129,15 +140,19 @@ describe("status", () => {
129
140
  print: (l) => lines.push(l),
130
141
  });
131
142
  expect(code).toBe(0);
132
- expect(lines.some((l) => l.includes("running"))).toBe(true);
143
+ // Pre-F: STATE was a two-column (PROCESS=running, HEALTH=ok) split.
144
+ // Post-F: collapsed to one column showing `active`.
145
+ expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
133
146
  expect(lines.some((l) => l.includes("4242"))).toBe(true);
134
- expect(lines.some((l) => l.includes("ok"))).toBe(true);
147
+ // Probe-detail continuation line is suppressed for active rows
148
+ // (the rollup is sufficient — no need to repeat "ok").
149
+ expect(lines.some((l) => l.includes("probe:"))).toBe(false);
135
150
  } finally {
136
151
  cleanup();
137
152
  }
138
153
  });
139
154
 
140
- test("known-stopped process skips probe and doesn't fail exit", async () => {
155
+ test("known-stopped process renders STATE=inactive, skips probe, exits 0", async () => {
141
156
  const { path, configDir, cleanup } = makeTempPath();
142
157
  try {
143
158
  upsertService(
@@ -159,7 +174,8 @@ describe("status", () => {
159
174
  });
160
175
  expect(code).toBe(0);
161
176
  expect(probed).toBe(false);
162
- expect(lines.some((l) => l.includes("stopped"))).toBe(true);
177
+ // Pre-F: PROCESS=stopped. Post-F: STATE=inactive.
178
+ expect(lines.some((l) => /\binactive\b/.test(l))).toBe(true);
163
179
  } finally {
164
180
  cleanup();
165
181
  }
@@ -447,7 +447,7 @@ describe("buildWellKnown", () => {
447
447
  displayName: "Unforced Brain",
448
448
  path: "/app/unforced-brain",
449
449
  oauthClientId: "client_def456",
450
- status: "pending-oauth",
450
+ status: "pending",
451
451
  },
452
452
  },
453
453
  };
@@ -471,7 +471,7 @@ describe("buildWellKnown", () => {
471
471
  path: "/app/unforced-brain",
472
472
  url: "https://x.example/app/unforced-brain",
473
473
  oauthClientId: "client_def456",
474
- status: "pending-oauth",
474
+ status: "pending",
475
475
  },
476
476
  ]);
477
477
  });
@@ -553,7 +553,7 @@ describe("buildWellKnown", () => {
553
553
  version: "0.3.1",
554
554
  iconUrl: "/i.svg",
555
555
  oauthClientId: "c1",
556
- status: "disabled",
556
+ status: "inactive",
557
557
  },
558
558
  minimal: { displayName: "Minimal", path: "/app/minimal" },
559
559
  },
@@ -568,7 +568,7 @@ describe("buildWellKnown", () => {
568
568
  expect(full?.tagline).toBe("Has it all");
569
569
  expect(full?.version).toBe("0.3.1");
570
570
  expect(full?.oauthClientId).toBe("c1");
571
- expect(full?.status).toBe("disabled");
571
+ expect(full?.status).toBe("inactive");
572
572
  // Minimal carries only the required fields — no optional keys.
573
573
  expect(minimal).toEqual({
574
574
  name: "minimal",
@@ -82,14 +82,41 @@ function formatRow(cells: string[], widths: number[]): string {
82
82
  .trimEnd();
83
83
  }
84
84
 
85
+ /**
86
+ * Canonical user-facing state vocabulary, per [parachute-patterns/patterns/
87
+ * design-system.md §6](../parachute-patterns/patterns/design-system.md)
88
+ * (workstream F). Replaces the pre-F two-column `PROCESS` (running/stopped)
89
+ * + `HEALTH` (ok/down/http <code>) split with a single rollup column the
90
+ * SPA, well-known doc, and CLI all share.
91
+ *
92
+ * active — process supervised, probe ok.
93
+ * pending — supervised, needs operator action (OAuth, config) — not
94
+ * reached from `parachute status` today; here for completeness
95
+ * so the union matches what the SPA renders.
96
+ * inactive — operator-stopped or never started.
97
+ * failing — supervised but probe failed (down / non-2xx).
98
+ */
99
+ type StateLabel = "active" | "pending" | "inactive" | "failing";
100
+
85
101
  interface StatusRow {
86
102
  service: string;
87
103
  port: string;
88
104
  version: string;
89
- processLabel: string;
105
+ /**
106
+ * Canonical four-state label per design-system.md §6 — what the operator
107
+ * reads. Derived from the pre-F (PROCESS, HEALTH) tuple at the emit-time
108
+ * site so the wider supervisor pipeline doesn't have to change shape.
109
+ */
110
+ stateLabel: StateLabel | "-";
90
111
  pidLabel: string;
91
112
  uptimeLabel: string;
92
- healthLabel: string;
113
+ /**
114
+ * Pre-F probe-result detail (`ok` / `http 503` / `ECONNREFUSED` / …).
115
+ * Kept on the row so the continuation-line context is still available
116
+ * when a row is `failing` and the operator wants to know why. Not a
117
+ * column; surfaced inline beneath the row only when non-trivial.
118
+ */
119
+ healthDetail: string;
93
120
  latencyLabel: string;
94
121
  sourceLabel: string;
95
122
  url: string | undefined;
@@ -137,7 +164,10 @@ function hubRow(
137
164
  if (proc.status === "unknown") return undefined;
138
165
  const port = readHubPort(configDir);
139
166
  const portLabel = port !== undefined ? String(port) : "-";
140
- const processLabel = proc.status === "running" ? "running" : "stopped";
167
+ // Hub doesn't self-probe (it'd be probing itself over loopback). Treat
168
+ // "running pidfile" as `active` and "stopped" as `inactive` — the same
169
+ // STATE rollup every other row uses, just without the probe input.
170
+ const stateLabel: StateLabel = proc.status === "running" ? "active" : "inactive";
141
171
  const pidLabel = proc.status === "running" && proc.pid !== undefined ? String(proc.pid) : "-";
142
172
  const uptimeLabel =
143
173
  proc.status === "running" && proc.startedAt ? formatUptime(proc.startedAt, nowDate) : "-";
@@ -146,10 +176,10 @@ function hubRow(
146
176
  service: "parachute-hub (internal)",
147
177
  port: portLabel,
148
178
  version: source.livePackageVersion ?? "-",
149
- processLabel,
179
+ stateLabel,
150
180
  pidLabel,
151
181
  uptimeLabel,
152
- healthLabel: "-",
182
+ healthDetail: "-",
153
183
  latencyLabel: "-",
154
184
  sourceLabel: formatInstallSourceLabel(source),
155
185
  url: port !== undefined ? `http://127.0.0.1:${port}` : undefined,
@@ -194,8 +224,6 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
194
224
  const short = shortNameForManifest(entry.name) ?? (entry.installDir ? entry.name : undefined);
195
225
  const proc = short ? processState(short, configDir, alive) : undefined;
196
226
 
197
- const processLabel =
198
- proc?.status === "running" ? "running" : proc?.status === "stopped" ? "stopped" : "-";
199
227
  const pidLabel =
200
228
  proc?.status === "running" && proc.pid !== undefined ? String(proc.pid) : "-";
201
229
  const uptimeLabel =
@@ -235,10 +263,14 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
235
263
  service: entry.name,
236
264
  port: String(entry.port),
237
265
  version: entry.version,
238
- processLabel,
266
+ // Operator deliberately stopped (or pidfile-but-dead) maps to
267
+ // `inactive` per design-system.md §6 — same surface as "never
268
+ // started." No probe is informative when we know the process
269
+ // is dead.
270
+ stateLabel: "inactive",
239
271
  pidLabel,
240
272
  uptimeLabel,
241
- healthLabel: "-",
273
+ healthDetail: "-",
242
274
  latencyLabel: "-",
243
275
  sourceLabel,
244
276
  url,
@@ -250,19 +282,32 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
250
282
  }
251
283
 
252
284
  const p = await probe(entry, fetchImpl, timeoutMs);
253
- const healthLabel = p.healthy
285
+ const healthDetail = p.healthy
254
286
  ? "ok"
255
287
  : p.statusCode !== undefined
256
288
  ? `http ${p.statusCode}`
257
289
  : (p.error ?? "down");
290
+ // STATE rollup per design-system.md §6:
291
+ // - probe ok → `active`
292
+ // - probe failed → `failing` (the probe ran, so the
293
+ // process is up enough to answer or
294
+ // refuse — it's failing, not stopped)
295
+ // - no PID file + probe fails → `failing` too (externally-managed
296
+ // row that's down is still "failing"
297
+ // from the operator's view)
298
+ // The `pending` state isn't reachable from `parachute status` today
299
+ // — pending-OAuth surfaces in the admin SPA, not the CLI. If a
300
+ // future surface adds it (e.g. supervisor reports `pending-config`
301
+ // for unconfigured modules), wire it here.
302
+ const stateLabel: StateLabel = p.healthy ? "active" : "failing";
258
303
  return {
259
304
  service: entry.name,
260
305
  port: String(entry.port),
261
306
  version: entry.version,
262
- processLabel,
307
+ stateLabel,
263
308
  pidLabel,
264
309
  uptimeLabel,
265
- healthLabel,
310
+ healthDetail,
266
311
  latencyLabel: `${p.latencyMs}ms`,
267
312
  sourceLabel,
268
313
  url,
@@ -279,25 +324,20 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
279
324
  const hub = hubRow(configDir, alive, nowDate, hubSrcDir, installSourceDeps);
280
325
  if (hub) rows.push(hub);
281
326
 
282
- const header = [
283
- "SERVICE",
284
- "PORT",
285
- "VERSION",
286
- "PROCESS",
287
- "PID",
288
- "UPTIME",
289
- "HEALTH",
290
- "LATENCY",
291
- "SOURCE",
292
- ];
327
+ // Header per design-system.md §6 "CLI status column shape":
328
+ // SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
329
+ // Pre-F shape was SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY
330
+ // SOURCE — workstream F collapses PROCESS + HEALTH into a single STATE
331
+ // column (both encoded the same rollup in two slots). LATENCY stays as
332
+ // a separate measurement column.
333
+ const header = ["SERVICE", "PORT", "VERSION", "STATE", "PID", "UPTIME", "LATENCY", "SOURCE"];
293
334
  const textRows = rows.map((r) => [
294
335
  r.service,
295
336
  r.port,
296
337
  r.version,
297
- r.processLabel,
338
+ r.stateLabel,
298
339
  r.pidLabel,
299
340
  r.uptimeLabel,
300
- r.healthLabel,
301
341
  r.latencyLabel,
302
342
  r.sourceLabel,
303
343
  ]);
@@ -305,18 +345,28 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
305
345
  Math.max(header[i]?.length ?? 0, ...textRows.map((r) => r[i]?.length ?? 0)),
306
346
  );
307
347
  print(formatRow(header, widths));
308
- // URL, drift, and stale notes stay on continuation lines rather than
309
- // columns. URLs are long (vault's MCP path runs ~40 chars); SOURCE labels
310
- // can be long for bun-linked rows. Spreading them across columns would
311
- // push the table well past 80 cols on every install — continuation lines
312
- // keep the table scannable. The " → " / " ! " prefixes group visually
313
- // with the row above without misleading the table widths.
348
+ // URL, drift, stale, and probe-failure detail stay on continuation lines
349
+ // rather than columns. URLs are long (vault's MCP path runs ~40 chars);
350
+ // SOURCE labels can be long for bun-linked rows. Spreading them across
351
+ // columns would push the table well past 80 cols on every install —
352
+ // continuation lines keep the table scannable. The " → " / " ! "
353
+ // prefixes group visually with the row above without misleading the
354
+ // table widths.
355
+ //
356
+ // When STATE collapses to `failing`, the pre-F `HEALTH` column's detail
357
+ // (`http 503`, `ECONNREFUSED`, etc.) surfaces on a continuation line so
358
+ // the operator can still see "what kind of failing" without the column
359
+ // overhead. Skipped on `active` / `inactive` rows (the detail is either
360
+ // trivial or N/A).
314
361
  for (let i = 0; i < textRows.length; i++) {
315
362
  const cells = textRows[i];
316
363
  const row = rows[i];
317
364
  if (!cells || !row) continue;
318
365
  print(formatRow(cells, widths));
319
366
  if (row.url) print(` → ${row.url}`);
367
+ if (row.stateLabel === "failing" && row.healthDetail !== "-" && row.healthDetail.length > 0) {
368
+ print(` ! probe: ${row.healthDetail}`);
369
+ }
320
370
  if (row.driftWarning) print(` ! ${row.driftWarning}`);
321
371
  if (row.staleNote) print(` ! ${row.staleNote}`);
322
372
  }
package/src/help.ts CHANGED
@@ -142,21 +142,36 @@ Examples:
142
142
  }
143
143
 
144
144
  export function statusHelp(): string {
145
- return `parachute status — show installed services, process state, health, install source
145
+ return `parachute status — show installed services, run state, install source
146
146
 
147
147
  Usage:
148
148
  parachute status
149
149
 
150
150
  What it does:
151
151
  Reads ~/.parachute/services.json. For each registered service:
152
- - checks PID file at ~/.parachute/<svc>/run/<svc>.pid → running/stopped
152
+ - checks PID file at ~/.parachute/<svc>/run/<svc>.pid
153
153
  - probes http://localhost:<port><health> (skipped for known-stopped processes)
154
154
  - classifies the install source as bun-linked (local checkout) or npm
155
155
 
156
- Stopped services show "-" for health and don't count toward the exit
157
- code they're an expected state after fresh install before \`parachute
158
- start\`. Running or externally-managed services that fail health checks
159
- do exit 1.
156
+ The STATE column rolls process state + probe result into one of four
157
+ canonical labels (per parachute-patterns/patterns/design-system.md §6):
158
+ active supervised, running, last probe ok
159
+ pending supervised, needs operator action (OAuth / config) —
160
+ not reachable from \`parachute status\` today; surfaces in
161
+ the admin SPA; kept here for completeness
162
+ inactive operator-stopped or never started (no probe attempted)
163
+ failing supervised but probe failed (down / non-2xx); a
164
+ continuation line (" ! probe: <detail>") prints the
165
+ underlying probe failure for diagnosis
166
+
167
+ Pre-workstream-F this column was two: PROCESS (running / stopped) and
168
+ HEALTH (ok / down / http <code>). Workstream F collapsed them onto the
169
+ single STATE column the SPA + well-known doc also speak.
170
+
171
+ Stopped services render as STATE=inactive and don't count toward the
172
+ exit code — they're an expected state after fresh install before
173
+ \`parachute start\`. Running or externally-managed services that fail
174
+ health checks render as STATE=failing and exit 1.
160
175
 
161
176
  A "STALE: services.json cached … live package.json …" continuation line
162
177
  appears under a row when a bun-linked service has been rebuilt but the
@@ -169,10 +184,10 @@ Exit codes:
169
184
 
170
185
  Example:
171
186
  $ parachute status
172
- SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY SOURCE
173
- parachute-vault 1940 0.2.4 running 12345 2h 13m ok 2ms bun-linked → parachute-vault @ 8aa167b
187
+ SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
188
+ parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
174
189
  → http://127.0.0.1:1940/vault/default/mcp
175
- parachute-app 1946 0.2.0 running 12346 2h 12m ok 3ms npm (0.2.0-rc.4)
190
+ parachute-app 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
176
191
  → http://127.0.0.1:1946/app/notes
177
192
  `;
178
193
  }
package/src/hub-server.ts CHANGED
@@ -1058,6 +1058,13 @@ export function hubFetch(
1058
1058
  issuer,
1059
1059
  loopbackPort,
1060
1060
  exposeHubOrigin: loadExposeHubOrigin(),
1061
+ // Trust the platform-injected public URL independently of the
1062
+ // configured issuer. On Render, an operator who set hub_origin
1063
+ // via the admin SPA (or via a stale db row) to a non-public URL
1064
+ // would otherwise reject legitimate browser POSTs that arrive
1065
+ // with the public Render URL as Origin. See origin-check.ts
1066
+ // jsdoc for the failure case this closes.
1067
+ platformOrigin: process.env.RENDER_EXTERNAL_URL,
1061
1068
  }),
1062
1069
  };
1063
1070
  };
@@ -1215,7 +1215,24 @@ export async function handleApproveClientPost(
1215
1215
  401,
1216
1216
  );
1217
1217
  }
1218
- if (!isSameOriginRequest(req, resolveBoundOrigins(deps))) {
1218
+ const bound = resolveBoundOrigins(deps);
1219
+ if (!isSameOriginRequest(req, bound)) {
1220
+ // Diagnostic: log the headers we saw + the bound set so an operator
1221
+ // chasing a rejection on a real deploy can see exactly what didn't
1222
+ // match. The same-origin check is the most opaque CSRF gate — without
1223
+ // this log, a misconfigured hub_settings.hub_origin or a proxy
1224
+ // stripping Origin/Referer produces a flat 403 with no way to debug.
1225
+ // Headers logged are non-sensitive (Origin/Referer/Host are public);
1226
+ // the bound set is hub's own configuration. Body content not logged.
1227
+ console.warn(
1228
+ `[oauth] approve POST same-origin check failed. headers: ` +
1229
+ `origin=${JSON.stringify(req.headers.get("origin"))} ` +
1230
+ `referer=${JSON.stringify(req.headers.get("referer"))} ` +
1231
+ `host=${JSON.stringify(req.headers.get("host"))} ` +
1232
+ `xff-host=${JSON.stringify(req.headers.get("x-forwarded-host"))} ` +
1233
+ `xff-proto=${JSON.stringify(req.headers.get("x-forwarded-proto"))}. ` +
1234
+ `bound origins: ${JSON.stringify(bound)}`,
1235
+ );
1219
1236
  return htmlError(
1220
1237
  "Cross-origin request rejected",
1221
1238
  "The approve form must be submitted from this hub's own origin.",