@openparachute/hub 0.5.13-rc.21 → 0.5.13-rc.29
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/README.md +21 -0
- package/package.json +1 -1
- package/src/__tests__/api-hub.test.ts +251 -0
- package/src/__tests__/hub-origin-resolution.test.ts +50 -0
- package/src/__tests__/serve.test.ts +9 -0
- package/src/__tests__/spawn-env-propagation.test.ts +78 -0
- package/src/admin-vaults.ts +6 -1
- package/src/api-hub.ts +201 -0
- package/src/api-modules-ops.ts +8 -1
- package/src/commands/auth.ts +7 -1
- package/src/commands/expose-auth-preflight.ts +6 -1
- package/src/commands/expose-cloudflare.ts +6 -1
- package/src/commands/expose-interactive.ts +7 -1
- package/src/commands/install.ts +7 -1
- package/src/commands/lifecycle.ts +10 -1
- package/src/commands/serve.ts +18 -8
- package/src/commands/upgrade.ts +5 -0
- package/src/commands/vault-tokens-create-interactive.ts +7 -1
- package/src/commands/vault.ts +3 -0
- package/src/hub-control.ts +6 -1
- package/src/hub-server.ts +32 -1
- package/src/supervisor.ts +4 -0
- package/src/tailscale/run.ts +7 -1
- package/web/ui/dist/assets/{index-BqjySZ_7.js → index-CG229ge6.js} +14 -14
- package/web/ui/dist/assets/{index-5Mj6FqPg.css → index-DArp3eO_.css} +1 -1
- package/web/ui/dist/index.html +2 -2
package/README.md
CHANGED
|
@@ -8,12 +8,33 @@ The hub coordinates the modules running on your machine: it installs them, runs
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
+
### Local (Bun)
|
|
12
|
+
|
|
11
13
|
```sh
|
|
12
14
|
bun add -g @openparachute/hub
|
|
13
15
|
```
|
|
14
16
|
|
|
15
17
|
Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
|
|
16
18
|
|
|
19
|
+
### Hosted (Render)
|
|
20
|
+
|
|
21
|
+
[](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
|
|
22
|
+
|
|
23
|
+
One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. Comes pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default.
|
|
24
|
+
|
|
25
|
+
**Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
|
|
26
|
+
|
|
27
|
+
After deploy completes:
|
|
28
|
+
|
|
29
|
+
1. Open Render Logs → search for `parachute-bootstrap-` to find your one-time admin setup token (printed in a prominent banner on first boot).
|
|
30
|
+
2. Visit your Render service URL's `/admin/setup` → paste the token → create your admin account.
|
|
31
|
+
3. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match.
|
|
32
|
+
4. Install modules via the admin SPA at `/admin/modules` (or via the wizard).
|
|
33
|
+
|
|
34
|
+
Operators who want env-var-driven seeding (CI, scripted deploys) can still set `PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD` manually in the Render dashboard — hub honors them when present.
|
|
35
|
+
|
|
36
|
+
Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
|
|
37
|
+
|
|
17
38
|
## First 5 minutes
|
|
18
39
|
|
|
19
40
|
```sh
|
package/package.json
CHANGED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/hub` — hub version + uptime + install-source surface for the
|
|
3
|
+
* admin SPA's version badge.
|
|
4
|
+
*
|
|
5
|
+
* Tests assert the contract:
|
|
6
|
+
* - 405 on non-GET (matches the shape of other /api/* read endpoints).
|
|
7
|
+
* - 401/403 on missing or under-scoped bearer (host:admin required).
|
|
8
|
+
* - Happy path returns the expected shape + uptime increments between
|
|
9
|
+
* calls.
|
|
10
|
+
* - PARACHUTE_HOME=/parachute (the Render Blueprint pin) overrides
|
|
11
|
+
* `source` to "container".
|
|
12
|
+
* - PARACHUTE_BUILD_TIME passes through as `container_build_time`.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { type HubStatusResponse, handleApiHub } from "../api-hub.ts";
|
|
20
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
21
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
22
|
+
import { mintOperatorToken } from "../operator-token.ts";
|
|
23
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
24
|
+
import { createUser } from "../users.ts";
|
|
25
|
+
|
|
26
|
+
interface Harness {
|
|
27
|
+
dir: string;
|
|
28
|
+
cleanup: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeHarness(): Harness {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-hub-"));
|
|
33
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
37
|
+
|
|
38
|
+
// `hubSrcDir` defaults to `dirname(import.meta.url)` of api-hub.ts — but in
|
|
39
|
+
// tests we drive it explicitly so test-side test-double dirs don't accidentally
|
|
40
|
+
// pick up the real package.json. Point it at this file's dir (under __tests__/)
|
|
41
|
+
// so the climb-to-package.json loop walks up to the repo root and finds the
|
|
42
|
+
// real hub package.json.
|
|
43
|
+
const HUB_SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
44
|
+
|
|
45
|
+
async function bootstrap(
|
|
46
|
+
dir: string,
|
|
47
|
+
): Promise<{ db: ReturnType<typeof openHubDb>; userId: string }> {
|
|
48
|
+
const db = openHubDb(hubDbPath(dir));
|
|
49
|
+
rotateSigningKey(db);
|
|
50
|
+
const u = await createUser(db, "owner", "pw");
|
|
51
|
+
return { db, userId: u.id };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getRequest(headers: Record<string, string> = {}): Request {
|
|
55
|
+
return new Request("http://localhost/api/hub", {
|
|
56
|
+
method: "GET",
|
|
57
|
+
headers,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("GET /api/hub (hub version + uptime badge surface)", () => {
|
|
62
|
+
test("405 on non-GET", async () => {
|
|
63
|
+
const h = makeHarness();
|
|
64
|
+
try {
|
|
65
|
+
const { db } = await bootstrap(h.dir);
|
|
66
|
+
try {
|
|
67
|
+
const req = new Request("http://localhost/api/hub", { method: "POST" });
|
|
68
|
+
const resp = await handleApiHub(req, { db, issuer: ISSUER });
|
|
69
|
+
expect(resp.status).toBe(405);
|
|
70
|
+
} finally {
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
h.cleanup();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("401 when no Authorization header", async () => {
|
|
79
|
+
const h = makeHarness();
|
|
80
|
+
try {
|
|
81
|
+
const { db } = await bootstrap(h.dir);
|
|
82
|
+
try {
|
|
83
|
+
const resp = await handleApiHub(getRequest(), { db, issuer: ISSUER });
|
|
84
|
+
expect(resp.status).toBe(401);
|
|
85
|
+
} finally {
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
h.cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("403 when bearer scope lacks parachute:host:admin", async () => {
|
|
94
|
+
const h = makeHarness();
|
|
95
|
+
try {
|
|
96
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
97
|
+
try {
|
|
98
|
+
const narrow = await signAccessToken(db, {
|
|
99
|
+
sub: userId,
|
|
100
|
+
// Adjacent host scope but NOT host:admin — host:auth is the
|
|
101
|
+
// tokens-registry scope, not the admin one. Confirms the gate
|
|
102
|
+
// checks the exact scope, not any host:* membership.
|
|
103
|
+
scopes: ["parachute:host:auth"],
|
|
104
|
+
audience: "hub",
|
|
105
|
+
clientId: "parachute-hub",
|
|
106
|
+
issuer: ISSUER,
|
|
107
|
+
ttlSeconds: 3600,
|
|
108
|
+
});
|
|
109
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${narrow.token}` }), {
|
|
110
|
+
db,
|
|
111
|
+
issuer: ISSUER,
|
|
112
|
+
});
|
|
113
|
+
expect(resp.status).toBe(403);
|
|
114
|
+
} finally {
|
|
115
|
+
db.close();
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
h.cleanup();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("happy path: returns version + started_at + uptime_ms + source", async () => {
|
|
123
|
+
const h = makeHarness();
|
|
124
|
+
try {
|
|
125
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
126
|
+
try {
|
|
127
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
128
|
+
const startedAt = new Date(Date.now() - 5000); // started 5s ago
|
|
129
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
130
|
+
db,
|
|
131
|
+
issuer: ISSUER,
|
|
132
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
133
|
+
startedAt,
|
|
134
|
+
// Override env so we're not at the mercy of the host's
|
|
135
|
+
// PARACHUTE_HOME (or PARACHUTE_BUILD_TIME) when the test runs.
|
|
136
|
+
env: {},
|
|
137
|
+
});
|
|
138
|
+
expect(resp.status).toBe(200);
|
|
139
|
+
const body = (await resp.json()) as HubStatusResponse;
|
|
140
|
+
// Version pulled from the real hub package.json — assert SemVer
|
|
141
|
+
// shape rather than pinning a specific number (otherwise this test
|
|
142
|
+
// breaks on every rc bump).
|
|
143
|
+
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
144
|
+
expect(body.started_at).toBe(startedAt.toISOString());
|
|
145
|
+
expect(body.uptime_ms).toBeGreaterThanOrEqual(5000);
|
|
146
|
+
// hubSrcDir points at the real repo's src/, so install-source
|
|
147
|
+
// classification will report bun-linked OR npm OR unknown — never
|
|
148
|
+
// "container" because we cleared PARACHUTE_HOME.
|
|
149
|
+
expect(body.source).not.toBe("container");
|
|
150
|
+
expect(body.container_build_time).toBeUndefined();
|
|
151
|
+
} finally {
|
|
152
|
+
db.close();
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
h.cleanup();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("uptime_ms increments between calls", async () => {
|
|
160
|
+
const h = makeHarness();
|
|
161
|
+
try {
|
|
162
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
163
|
+
try {
|
|
164
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
165
|
+
const startedAt = new Date("2026-05-23T14:00:00.000Z");
|
|
166
|
+
// Drive "now" so the assertion isn't a flaky timing test.
|
|
167
|
+
const first = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
168
|
+
db,
|
|
169
|
+
issuer: ISSUER,
|
|
170
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
171
|
+
startedAt,
|
|
172
|
+
now: () => new Date("2026-05-23T14:00:05.000Z"),
|
|
173
|
+
env: {},
|
|
174
|
+
});
|
|
175
|
+
const second = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
176
|
+
db,
|
|
177
|
+
issuer: ISSUER,
|
|
178
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
179
|
+
startedAt,
|
|
180
|
+
now: () => new Date("2026-05-23T14:00:08.000Z"),
|
|
181
|
+
env: {},
|
|
182
|
+
});
|
|
183
|
+
const firstBody = (await first.json()) as HubStatusResponse;
|
|
184
|
+
const secondBody = (await second.json()) as HubStatusResponse;
|
|
185
|
+
expect(firstBody.uptime_ms).toBe(5000);
|
|
186
|
+
expect(secondBody.uptime_ms).toBe(8000);
|
|
187
|
+
expect(secondBody.uptime_ms).toBeGreaterThan(firstBody.uptime_ms);
|
|
188
|
+
// started_at stays stable across calls (captured once at process
|
|
189
|
+
// start, not per-request).
|
|
190
|
+
expect(secondBody.started_at).toBe(firstBody.started_at);
|
|
191
|
+
} finally {
|
|
192
|
+
db.close();
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
h.cleanup();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("PARACHUTE_HOME=/parachute overrides source to 'container'", async () => {
|
|
200
|
+
const h = makeHarness();
|
|
201
|
+
try {
|
|
202
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
203
|
+
try {
|
|
204
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
205
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
206
|
+
db,
|
|
207
|
+
issuer: ISSUER,
|
|
208
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
209
|
+
env: { PARACHUTE_HOME: "/parachute" },
|
|
210
|
+
});
|
|
211
|
+
expect(resp.status).toBe(200);
|
|
212
|
+
const body = (await resp.json()) as HubStatusResponse;
|
|
213
|
+
expect(body.source).toBe("container");
|
|
214
|
+
// bun_linked_path is suppressed under container source — operators
|
|
215
|
+
// on Render don't have a meaningful "checkout path" to surface.
|
|
216
|
+
expect(body.bun_linked_path).toBeUndefined();
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
h.cleanup();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("PARACHUTE_BUILD_TIME passes through as container_build_time", async () => {
|
|
226
|
+
const h = makeHarness();
|
|
227
|
+
try {
|
|
228
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
229
|
+
try {
|
|
230
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
231
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
232
|
+
db,
|
|
233
|
+
issuer: ISSUER,
|
|
234
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
235
|
+
env: {
|
|
236
|
+
PARACHUTE_HOME: "/parachute",
|
|
237
|
+
PARACHUTE_BUILD_TIME: "2026-05-23T14:21:00.000Z",
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
expect(resp.status).toBe(200);
|
|
241
|
+
const body = (await resp.json()) as HubStatusResponse;
|
|
242
|
+
expect(body.container_build_time).toBe("2026-05-23T14:21:00.000Z");
|
|
243
|
+
expect(body.source).toBe("container");
|
|
244
|
+
} finally {
|
|
245
|
+
db.close();
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
h.cleanup();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -117,6 +117,56 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
117
117
|
// Pass 3 — back to request origin.
|
|
118
118
|
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
test("X-Forwarded-Proto: https upgrades the request-origin fallback", () => {
|
|
122
|
+
// Render / Tailscale Funnel / cloudflared terminate TLS at the edge
|
|
123
|
+
// and forward plain HTTP. Without honoring the header, hub publishes
|
|
124
|
+
// `http://...` in OAuth discovery — mixed-content blocked when the
|
|
125
|
+
// page loaded over https://. See hub#355 (the notes app's
|
|
126
|
+
// /oauth/register call surfaced this).
|
|
127
|
+
const r = new Request("http://parachute-hub.onrender.com/.well-known/oauth-authorization-server", {
|
|
128
|
+
method: "GET",
|
|
129
|
+
headers: { "X-Forwarded-Proto": "https" },
|
|
130
|
+
});
|
|
131
|
+
expect(resolveIssuer(r, db, undefined)).toBe("https://parachute-hub.onrender.com");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("X-Forwarded-Proto with comma-separated values takes the first", () => {
|
|
135
|
+
// Multi-hop proxies append; the leftmost is the original client → edge
|
|
136
|
+
// hop. RFC-style parsing (consistent with isHttpsRequest).
|
|
137
|
+
const r = new Request("http://hub.internal/oauth/token", {
|
|
138
|
+
method: "GET",
|
|
139
|
+
headers: { "X-Forwarded-Proto": "https, http" },
|
|
140
|
+
});
|
|
141
|
+
expect(resolveIssuer(r, db, undefined)).toBe("https://hub.internal");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("missing X-Forwarded-Proto leaves the URL scheme as-is (localhost dev)", () => {
|
|
145
|
+
// No reverse proxy → no header → keep http for the local-dev shape.
|
|
146
|
+
// Operators on plain HTTP localhost depend on this.
|
|
147
|
+
const r = new Request("http://127.0.0.1:1939/oauth/token", { method: "GET" });
|
|
148
|
+
expect(resolveIssuer(r, db, undefined)).toBe("http://127.0.0.1:1939");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("X-Forwarded-Proto is IGNORED when hub_settings or env wins", () => {
|
|
152
|
+
// Precedence guard: X-Forwarded-Proto should only affect the
|
|
153
|
+
// request-origin fallback branch. Explicit operator config
|
|
154
|
+
// (settings row, env var) always wins as-is, including its scheme.
|
|
155
|
+
// Without this guard, a future refactor could accidentally let the
|
|
156
|
+
// header override an operator's deliberate choice.
|
|
157
|
+
const r = new Request("http://hub.internal/oauth/token", {
|
|
158
|
+
method: "GET",
|
|
159
|
+
headers: { "X-Forwarded-Proto": "https" },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Env layer wins, even though the header says https — the env value
|
|
163
|
+
// is returned verbatim (preserving whatever scheme the operator set).
|
|
164
|
+
expect(resolveIssuer(r, db, "http://configured.example")).toBe("http://configured.example");
|
|
165
|
+
|
|
166
|
+
// Settings layer wins above env, also verbatim.
|
|
167
|
+
setHubOrigin(db, "http://settings.example");
|
|
168
|
+
expect(resolveIssuer(r, db, "https://env.example")).toBe("http://settings.example");
|
|
169
|
+
});
|
|
120
170
|
});
|
|
121
171
|
|
|
122
172
|
describe("resolveIssuerSource — attribution for SPA", () => {
|
|
@@ -184,6 +184,15 @@ describe("formatBootstrapTokenBanner", () => {
|
|
|
184
184
|
expect(line.startsWith("[wizard]")).toBe(true);
|
|
185
185
|
}
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
test("uses ═ delimiters and an ALL-CAPS heading so operators spot the block in log viewers", () => {
|
|
189
|
+
const banner = formatBootstrapTokenBanner("parachute-bootstrap-visual-token");
|
|
190
|
+
// The ═ box-drawing char is the visual cue an operator scrolling
|
|
191
|
+
// Render's log tab keys off; this assertion locks the new shape so
|
|
192
|
+
// a stylistic regression doesn't silently demote the banner.
|
|
193
|
+
expect(banner).toContain("═");
|
|
194
|
+
expect(banner).toContain("PARACHUTE BOOTSTRAP TOKEN");
|
|
195
|
+
});
|
|
187
196
|
});
|
|
188
197
|
|
|
189
198
|
// --- bootstrap-token generation under needs-setup (Issue 1 wiring) -------
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for hub#349: Bun.spawn defaults to an EMPTY env, which meant
|
|
3
|
+
* subprocess `bun add -g` didn't see TMPDIR, BUN_INSTALL, or any other env vars
|
|
4
|
+
* set by the Dockerfile / Render env. All `defaultRun`-style helpers were
|
|
5
|
+
* updated to pass `env: process.env`.
|
|
6
|
+
*
|
|
7
|
+
* This test asserts that property end-to-end: spawn a real child via `bun -e`
|
|
8
|
+
* and have it print one parent-set env var. Pre-fix, the child would not see
|
|
9
|
+
* the var; post-fix, it does.
|
|
10
|
+
*
|
|
11
|
+
* We exercise the production path by importing one representative helper.
|
|
12
|
+
* The full set of seven explicit + several inherited fix sites all use the
|
|
13
|
+
* same `env: process.env` pattern; testing one is sufficient to lock the
|
|
14
|
+
* pattern in place — the others are mechanical applications of it.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
describe("Bun.spawn env propagation (hub#349)", () => {
|
|
19
|
+
test("child process sees parent env when defaultRun-style helper is used", async () => {
|
|
20
|
+
// Unique marker so we can't false-positive against leftover env from
|
|
21
|
+
// another test or the harness itself.
|
|
22
|
+
const markerKey = "PARACHUTE_HUB_SPAWN_ENV_TEST_MARKER";
|
|
23
|
+
const markerValue = `marker-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
24
|
+
|
|
25
|
+
const originalValue = process.env[markerKey];
|
|
26
|
+
process.env[markerKey] = markerValue;
|
|
27
|
+
try {
|
|
28
|
+
// Spawn a child the same way every defaultRun helper does:
|
|
29
|
+
// `env: process.env`. The child prints its view of the marker var.
|
|
30
|
+
const proc = Bun.spawn(
|
|
31
|
+
["bun", "-e", `process.stdout.write(process.env.${markerKey} ?? "MISSING")`],
|
|
32
|
+
{
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "pipe",
|
|
35
|
+
env: process.env,
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
const stdout = await new Response(proc.stdout).text();
|
|
39
|
+
const exitCode = await proc.exited;
|
|
40
|
+
|
|
41
|
+
expect(exitCode).toBe(0);
|
|
42
|
+
expect(stdout).toBe(markerValue);
|
|
43
|
+
} finally {
|
|
44
|
+
if (originalValue === undefined) delete process.env[markerKey];
|
|
45
|
+
else process.env[markerKey] = originalValue;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("child process does NOT see parent env when env is omitted (negative control)", async () => {
|
|
50
|
+
// The bug we're guarding against: without `env: process.env`, Bun.spawn
|
|
51
|
+
// hands the child an empty env. This test pins the failure mode so a
|
|
52
|
+
// future regression (someone removing `env: process.env`) is caught here,
|
|
53
|
+
// not in production on Render.
|
|
54
|
+
const markerKey = "PARACHUTE_HUB_SPAWN_ENV_TEST_MARKER_NEG";
|
|
55
|
+
const markerValue = `marker-${Date.now()}`;
|
|
56
|
+
|
|
57
|
+
const originalValue = process.env[markerKey];
|
|
58
|
+
process.env[markerKey] = markerValue;
|
|
59
|
+
try {
|
|
60
|
+
const proc = Bun.spawn(
|
|
61
|
+
["bun", "-e", `process.stdout.write(process.env.${markerKey} ?? "MISSING")`],
|
|
62
|
+
{
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
65
|
+
// intentionally NO env: process.env
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
const stdout = await new Response(proc.stdout).text();
|
|
69
|
+
const exitCode = await proc.exited;
|
|
70
|
+
|
|
71
|
+
expect(exitCode).toBe(0);
|
|
72
|
+
expect(stdout).toBe("MISSING");
|
|
73
|
+
} finally {
|
|
74
|
+
if (originalValue === undefined) delete process.env[markerKey];
|
|
75
|
+
else process.env[markerKey] = originalValue;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/admin-vaults.ts
CHANGED
|
@@ -206,7 +206,12 @@ function buildEntry(
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
|
|
209
|
-
|
|
209
|
+
// Inherit env so the child sees PATH, HOME, BUN_INSTALL, etc. Bun.spawn
|
|
210
|
+
// defaults to empty env — see api-modules-ops.ts:defaultRun for the rationale.
|
|
211
|
+
const proc = Bun.spawn([...cmd], {
|
|
212
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
213
|
+
env: process.env,
|
|
214
|
+
});
|
|
210
215
|
// Drain both pipes in parallel — leaving stderr unread can deadlock long
|
|
211
216
|
// installs once the OS pipe buffer fills (#97). Captured stderr is folded
|
|
212
217
|
// into the orchestration error message on non-zero exit.
|
package/src/api-hub.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
6
|
+
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
7
|
+
import { detectHubInstallSource } from "./install-source.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `GET /api/hub` — hub runtime info for the admin SPA's version badge.
|
|
11
|
+
*
|
|
12
|
+
* Operators (especially Render deployers tracking auto-deploys from `main`)
|
|
13
|
+
* need to tell at a glance: what version is this hub running, when did it
|
|
14
|
+
* last restart, is this the version I expect? The CLI surface `parachute
|
|
15
|
+
* status` already shows the same data; this endpoint mirrors it for the
|
|
16
|
+
* browser SPA.
|
|
17
|
+
*
|
|
18
|
+
* Bearer-gated on `parachute:host:admin` (same as `/vaults` and `/api/grants`).
|
|
19
|
+
* Operators-only: the version + uptime + install-source fingerprint isn't
|
|
20
|
+
* sensitive per se, but it's adjacent to operator-only diagnostics so it
|
|
21
|
+
* lives behind the same gate as the rest of the admin surface.
|
|
22
|
+
*
|
|
23
|
+
* Response shape (snake_case to match other `/api/*` endpoints):
|
|
24
|
+
*
|
|
25
|
+
* {
|
|
26
|
+
* "version": "0.5.13-rc.23",
|
|
27
|
+
* "started_at": "2026-05-23T14:23:45.000Z",
|
|
28
|
+
* "uptime_ms": 8025000,
|
|
29
|
+
* "source": "bun-linked" | "npm" | "container" | "unknown",
|
|
30
|
+
* "bun_linked_path": "/Users/.../parachute-hub" // optional
|
|
31
|
+
* "git_head": "a53af21" // optional
|
|
32
|
+
* "container_build_time": "2026-05-23T14:21:00Z" // optional
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* - `source` reuses `detectHubInstallSource` from install-source.ts (the
|
|
36
|
+
* same logic `parachute status` uses for the SOURCE column). The fourth
|
|
37
|
+
* value `"container"` is hub-specific: when `process.env.PARACHUTE_HOME
|
|
38
|
+
* === "/parachute"` (the Render Blueprint pin) we override `bun-linked`
|
|
39
|
+
* → `container` so the badge tells operators "you're on Render", not
|
|
40
|
+
* the misleading "bun-linked from /app".
|
|
41
|
+
* - `started_at` is captured once at module load (see `HUB_PROCESS_STARTED_AT`).
|
|
42
|
+
* `uptime_ms` is computed server-side at request time so the client
|
|
43
|
+
* doesn't have to deal with clock skew.
|
|
44
|
+
* - `container_build_time` reads `process.env.PARACHUTE_BUILD_TIME` —
|
|
45
|
+
* passed through by the Dockerfile when set as a build arg (operators
|
|
46
|
+
* can set this themselves in render.yaml or via `--build-arg` to surface
|
|
47
|
+
* the image build time). Not surfaced when unset.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
export interface ApiHubDeps {
|
|
51
|
+
db: Database;
|
|
52
|
+
/** Hub origin — used to validate the bearer's `iss`. */
|
|
53
|
+
issuer: string;
|
|
54
|
+
/**
|
|
55
|
+
* Override the directory used to locate the hub's package.json and to
|
|
56
|
+
* classify install source. Defaults to `dirname(import.meta.url)` —
|
|
57
|
+
* which is `<repo>/src/` for normal layouts. Test seam.
|
|
58
|
+
*/
|
|
59
|
+
hubSrcDir?: string;
|
|
60
|
+
/** Override `process.env` lookups. Test seam. */
|
|
61
|
+
env?: Record<string, string | undefined>;
|
|
62
|
+
/** Override the started-at timestamp. Test seam — defaults to the module-level capture. */
|
|
63
|
+
startedAt?: Date;
|
|
64
|
+
/** Override "now" for uptime computation. Test seam. */
|
|
65
|
+
now?: () => Date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The hub process's start time. Captured exactly once at module load and
|
|
70
|
+
* re-exported so the request handler returns a stable value across calls
|
|
71
|
+
* (and so the `parachute status` CLI surface in the same process can pull
|
|
72
|
+
* it from one source).
|
|
73
|
+
*/
|
|
74
|
+
export const HUB_PROCESS_STARTED_AT: Date = new Date();
|
|
75
|
+
|
|
76
|
+
/** Wire shape returned by `GET /api/hub`. Snake-case to match other `/api/*` endpoints. */
|
|
77
|
+
export interface HubStatusResponse {
|
|
78
|
+
version: string;
|
|
79
|
+
started_at: string;
|
|
80
|
+
uptime_ms: number;
|
|
81
|
+
source: "bun-linked" | "npm" | "container" | "unknown";
|
|
82
|
+
bun_linked_path?: string;
|
|
83
|
+
git_head?: string;
|
|
84
|
+
container_build_time?: string;
|
|
85
|
+
/** Render-set runtime env vars surfaced when running on Render. Sourced from RENDER_GIT_COMMIT + RENDER_GIT_BRANCH. */
|
|
86
|
+
render_commit?: string;
|
|
87
|
+
render_branch?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function handleApiHub(req: Request, deps: ApiHubDeps): Promise<Response> {
|
|
91
|
+
if (req.method !== "GET") {
|
|
92
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Bearer-gate on `parachute:host:admin`. Same shape as the other admin
|
|
96
|
+
// endpoints — SPA mints via /admin/host-admin-token.
|
|
97
|
+
try {
|
|
98
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return adminAuthErrorResponse(err);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const env = deps.env ?? process.env;
|
|
104
|
+
const startedAt = deps.startedAt ?? HUB_PROCESS_STARTED_AT;
|
|
105
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
106
|
+
const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
|
|
107
|
+
|
|
108
|
+
// detectHubInstallSource climbs from src/ to the nearest package.json,
|
|
109
|
+
// reads its version, classifies as bun-linked vs npm vs unknown.
|
|
110
|
+
const source = detectHubInstallSource(hubSrcDir);
|
|
111
|
+
|
|
112
|
+
// Read version from the nearest package.json (same logic the install-source
|
|
113
|
+
// detector ran). We don't reuse `source.livePackageVersion` because the
|
|
114
|
+
// unknown-source branch leaves it undefined — but the version field on the
|
|
115
|
+
// response must always be present.
|
|
116
|
+
const version = readHubVersion(hubSrcDir) ?? "unknown";
|
|
117
|
+
|
|
118
|
+
// Container override: the Render Blueprint pins PARACHUTE_HOME=/parachute,
|
|
119
|
+
// which is a reliable container-mode signal. The install-source detector
|
|
120
|
+
// would label this `bun-linked` (the image runs from /app/src, not bun
|
|
121
|
+
// globals), which is technically true but misleading for the operator —
|
|
122
|
+
// "container" is what they actually want to see.
|
|
123
|
+
const isContainer = env.PARACHUTE_HOME === "/parachute";
|
|
124
|
+
|
|
125
|
+
const body: HubStatusResponse = {
|
|
126
|
+
version,
|
|
127
|
+
started_at: startedAt.toISOString(),
|
|
128
|
+
uptime_ms: Math.max(0, now.getTime() - startedAt.getTime()),
|
|
129
|
+
source: isContainer ? "container" : source.kind,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (!isContainer && source.kind === "bun-linked" && source.path) {
|
|
133
|
+
body.bun_linked_path = source.path;
|
|
134
|
+
}
|
|
135
|
+
// `git_head` is meaningful only for bun-linked dev installs — the container
|
|
136
|
+
// image strips `.git` at build time so source.gitHead is always undefined
|
|
137
|
+
// there. Explicit !isContainer guard for symmetry with bun_linked_path.
|
|
138
|
+
if (!isContainer && source.gitHead) {
|
|
139
|
+
body.git_head = source.gitHead;
|
|
140
|
+
}
|
|
141
|
+
const buildTime = env.PARACHUTE_BUILD_TIME;
|
|
142
|
+
if (typeof buildTime === "string" && buildTime.length > 0) {
|
|
143
|
+
body.container_build_time = buildTime;
|
|
144
|
+
}
|
|
145
|
+
// Render exposes RENDER_GIT_COMMIT + RENDER_GIT_BRANCH at runtime when
|
|
146
|
+
// the container is running on Render. Surface for operator diagnostics
|
|
147
|
+
// (the commit SHA is a more rigorous identity than build-time wall-clock).
|
|
148
|
+
// Container-mode only — local dev never has these set.
|
|
149
|
+
if (isContainer) {
|
|
150
|
+
const renderCommit = env.RENDER_GIT_COMMIT;
|
|
151
|
+
if (typeof renderCommit === "string" && renderCommit.length > 0) {
|
|
152
|
+
body.render_commit = renderCommit;
|
|
153
|
+
}
|
|
154
|
+
const renderBranch = env.RENDER_GIT_BRANCH;
|
|
155
|
+
if (typeof renderBranch === "string" && renderBranch.length > 0) {
|
|
156
|
+
body.render_branch = renderBranch;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return new Response(JSON.stringify(body), {
|
|
161
|
+
status: 200,
|
|
162
|
+
headers: {
|
|
163
|
+
"content-type": "application/json",
|
|
164
|
+
"cache-control": "no-store",
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read the hub's package.json `version` field. Climbs from `srcDir` to the
|
|
171
|
+
* nearest package.json (same pattern as `findNearestPackageDir` in
|
|
172
|
+
* install-source.ts). Returns undefined if the file's missing or malformed.
|
|
173
|
+
*/
|
|
174
|
+
function readHubVersion(srcDir: string): string | undefined {
|
|
175
|
+
let current = resolve(srcDir);
|
|
176
|
+
for (let i = 0; i < 16; i++) {
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(readFileSync(join(current, "package.json"), "utf8")) as unknown;
|
|
179
|
+
if (parsed && typeof parsed === "object") {
|
|
180
|
+
const v = (parsed as Record<string, unknown>).version;
|
|
181
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// No package.json here — climb.
|
|
185
|
+
}
|
|
186
|
+
const parent = dirname(current);
|
|
187
|
+
if (parent === current) return undefined;
|
|
188
|
+
current = parent;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
194
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
195
|
+
status,
|
|
196
|
+
headers: {
|
|
197
|
+
"content-type": "application/json",
|
|
198
|
+
"cache-control": "no-store",
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -322,7 +322,14 @@ async function resolveSpawnSpec(
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
function defaultRun(cmd: readonly string[]): Promise<number> {
|
|
325
|
-
|
|
325
|
+
// Inherit env so child `bun add` sees TMPDIR, BUN_INSTALL, PARACHUTE_*,
|
|
326
|
+
// etc. set by the Dockerfile / Render env. Bun.spawn defaults to empty
|
|
327
|
+
// env — without this, bun-add fails with cross-mount rename errors on
|
|
328
|
+
// Render (where TMPDIR points at the persistent disk). See hub#349.
|
|
329
|
+
const proc = Bun.spawn([...cmd], {
|
|
330
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
331
|
+
env: process.env,
|
|
332
|
+
});
|
|
326
333
|
return proc.exited;
|
|
327
334
|
}
|
|
328
335
|
|