@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 +1 -1
- package/src/__tests__/api-modules.test.ts +56 -4
- package/src/__tests__/origin-check.test.ts +36 -0
- package/src/__tests__/services-manifest.test.ts +74 -1
- package/src/__tests__/status.test.ts +26 -10
- package/src/__tests__/well-known.test.ts +4 -4
- package/src/commands/status.ts +81 -31
- package/src/help.ts +24 -9
- package/src/hub-server.ts +7 -0
- package/src/oauth-handlers.ts +18 -1
- package/src/origin-check.ts +12 -0
- package/src/services-manifest.ts +59 -21
- package/web/ui/dist/assets/{index-DArp3eO_.css → index-CGPyOfGK.css} +1 -1
- package/web/ui/dist/assets/{index-C17QvwDb.js → index-DNTukKZw.js} +13 -13
- package/web/ui/dist/index.html +2 -2
package/package.json
CHANGED
|
@@ -519,7 +519,7 @@ describe("GET /api/modules", () => {
|
|
|
519
519
|
techne: {
|
|
520
520
|
displayName: "Techne",
|
|
521
521
|
path: "/vault/techne",
|
|
522
|
-
status: "pending
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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("
|
|
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",
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
stateLabel,
|
|
150
180
|
pidLabel,
|
|
151
181
|
uptimeLabel,
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
307
|
+
stateLabel,
|
|
263
308
|
pidLabel,
|
|
264
309
|
uptimeLabel,
|
|
265
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
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
|
|
309
|
-
// columns. URLs are long (vault's MCP path runs ~40 chars);
|
|
310
|
-
// can be long for bun-linked rows. Spreading them across
|
|
311
|
-
// push the table well past 80 cols on every install —
|
|
312
|
-
// keep the table scannable. The " → " / " ! "
|
|
313
|
-
// with the row above without misleading the
|
|
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,
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
173
|
-
parachute-vault 1940 0.2.4
|
|
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
|
|
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
|
};
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -1215,7 +1215,24 @@ export async function handleApproveClientPost(
|
|
|
1215
1215
|
401,
|
|
1216
1216
|
);
|
|
1217
1217
|
}
|
|
1218
|
-
|
|
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.",
|