@openparachute/hub 0.7.1 → 0.7.2
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 +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin screen-lock management API (hub admin-lock feature).
|
|
3
|
+
*
|
|
4
|
+
* All endpoints are session-cookie-gated to the first admin — the same gate
|
|
5
|
+
* the admin-token mints use (`isFirstAdmin`). These manage the lock itself, so
|
|
6
|
+
* they are NOT behind the lock gate (you must be able to unlock + set the PIN
|
|
7
|
+
* even when the surface is locked).
|
|
8
|
+
*
|
|
9
|
+
* GET /api/admin-lock → status { configured, locked, idle_seconds, unlock_seconds_remaining }
|
|
10
|
+
* POST /api/admin-lock/set → set the FIRST PIN (no PIN configured yet)
|
|
11
|
+
* POST /api/admin-lock/change → rotate PIN (requires current PIN or unlocked session)
|
|
12
|
+
* POST /api/admin-lock/remove → turn the feature OFF (requires current PIN or unlocked session)
|
|
13
|
+
* POST /api/admin-lock/unlock → verify PIN, open an unlock window
|
|
14
|
+
* POST /api/admin-lock/lock → "Lock now" — drop the session's unlock window
|
|
15
|
+
* POST /api/admin-lock/heartbeat → slide the idle window forward on activity
|
|
16
|
+
*
|
|
17
|
+
* The chicken-and-egg: setting the FIRST PIN is an authenticated admin action
|
|
18
|
+
* (logged-in session, no PIN yet → allowed). Once a PIN exists, change/remove
|
|
19
|
+
* require proving knowledge of the current PIN OR an already-unlocked session
|
|
20
|
+
* (the operator just unlocked, so they hold the PIN — re-typing would be
|
|
21
|
+
* friction). Setting over an existing PIN is rejected (use change).
|
|
22
|
+
*
|
|
23
|
+
* State-changing POSTs additionally require a CSRF token (`__csrf`) — these
|
|
24
|
+
* are JSON endpoints on the same origin as the cookie, and SameSite=Lax alone
|
|
25
|
+
* doesn't stop same-site CSRF (see src/csrf.ts). The SPA sources the token
|
|
26
|
+
* from `/api/me`.
|
|
27
|
+
*/
|
|
28
|
+
import type { Database } from "bun:sqlite";
|
|
29
|
+
import {
|
|
30
|
+
clampIdleSeconds,
|
|
31
|
+
clearPin,
|
|
32
|
+
getIdleSeconds,
|
|
33
|
+
isLockConfigured,
|
|
34
|
+
isSessionUnlocked,
|
|
35
|
+
lockSession,
|
|
36
|
+
recordUnlock,
|
|
37
|
+
refreshActivity,
|
|
38
|
+
setIdleSeconds,
|
|
39
|
+
setPin,
|
|
40
|
+
unlockLimiter,
|
|
41
|
+
unlockSecondsRemaining,
|
|
42
|
+
validatePin,
|
|
43
|
+
verifyPin,
|
|
44
|
+
} from "./admin-lock.ts";
|
|
45
|
+
import { verifyCsrfToken } from "./csrf.ts";
|
|
46
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
47
|
+
import { isFirstAdmin } from "./users.ts";
|
|
48
|
+
|
|
49
|
+
export interface AdminLockDeps {
|
|
50
|
+
db: Database;
|
|
51
|
+
/** Injectable clock for tests. */
|
|
52
|
+
now?: () => Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function json(status: number, body: unknown): Response {
|
|
56
|
+
return new Response(JSON.stringify(body), {
|
|
57
|
+
status,
|
|
58
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function err(status: number, error: string, description: string): Response {
|
|
63
|
+
return json(status, { error, error_description: description });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the first-admin session for this request, or an error Response.
|
|
68
|
+
* Mirrors the gate the token-mint endpoints apply, so the lock-management
|
|
69
|
+
* surface has the same audience.
|
|
70
|
+
*/
|
|
71
|
+
function requireAdminSession(
|
|
72
|
+
db: Database,
|
|
73
|
+
req: Request,
|
|
74
|
+
): { ok: true; sessionId: string; userId: string } | { ok: false; res: Response } {
|
|
75
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
76
|
+
const session = sid ? findSession(db, sid) : null;
|
|
77
|
+
if (!session || !sid) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
res: err(401, "unauthenticated", "no admin session — sign in at /login first"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!isFirstAdmin(db, session.userId)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
res: err(
|
|
87
|
+
403,
|
|
88
|
+
"not_admin",
|
|
89
|
+
"admin lock management is restricted to the hub admin — your account home is at /account/",
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, sessionId: sid, userId: session.userId };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
|
|
97
|
+
try {
|
|
98
|
+
const body = (await req.json()) as unknown;
|
|
99
|
+
return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
100
|
+
} catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkCsrf(req: Request, body: Record<string, unknown>): boolean {
|
|
106
|
+
const token = typeof body.__csrf === "string" ? body.__csrf : null;
|
|
107
|
+
return verifyCsrfToken(req, token);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The lock-management router. `subpath` is the path AFTER `/api/admin-lock`
|
|
112
|
+
* (e.g. "" for status, "/unlock", "/set"). The hub-server dispatcher slices it.
|
|
113
|
+
*/
|
|
114
|
+
export async function handleAdminLock(
|
|
115
|
+
req: Request,
|
|
116
|
+
subpath: string,
|
|
117
|
+
deps: AdminLockDeps,
|
|
118
|
+
): Promise<Response> {
|
|
119
|
+
const { db } = deps;
|
|
120
|
+
const now = deps.now ?? (() => new Date());
|
|
121
|
+
|
|
122
|
+
// GET status (no body, no CSRF — read-only).
|
|
123
|
+
if (subpath === "" || subpath === "/") {
|
|
124
|
+
if (req.method !== "GET") return err(405, "method_not_allowed", "use GET");
|
|
125
|
+
const gate = requireAdminSession(db, req);
|
|
126
|
+
if (!gate.ok) return gate.res;
|
|
127
|
+
const configured = isLockConfigured(db);
|
|
128
|
+
const nowMs = now().getTime();
|
|
129
|
+
const locked = configured && !isSessionUnlocked(gate.sessionId, nowMs);
|
|
130
|
+
return json(200, {
|
|
131
|
+
configured,
|
|
132
|
+
locked,
|
|
133
|
+
idle_seconds: getIdleSeconds(db),
|
|
134
|
+
unlock_seconds_remaining: configured ? unlockSecondsRemaining(gate.sessionId, nowMs) : 0,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Everything below is a POST.
|
|
139
|
+
if (req.method !== "POST") return err(405, "method_not_allowed", "use POST");
|
|
140
|
+
const gate = requireAdminSession(db, req);
|
|
141
|
+
if (!gate.ok) return gate.res;
|
|
142
|
+
const body = await readJsonBody(req);
|
|
143
|
+
if (!checkCsrf(req, body)) {
|
|
144
|
+
return err(403, "csrf_failed", "missing or invalid CSRF token");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
switch (subpath) {
|
|
148
|
+
case "/set":
|
|
149
|
+
return handleSet(db, gate.sessionId, body);
|
|
150
|
+
case "/change":
|
|
151
|
+
return handleChange(db, gate.sessionId, body, now);
|
|
152
|
+
case "/remove":
|
|
153
|
+
return handleRemove(db, gate.sessionId, body, now);
|
|
154
|
+
case "/unlock":
|
|
155
|
+
return handleUnlock(db, gate.sessionId, gate.userId, body, now);
|
|
156
|
+
case "/lock":
|
|
157
|
+
lockSession(gate.sessionId);
|
|
158
|
+
return json(200, { locked: true });
|
|
159
|
+
case "/heartbeat":
|
|
160
|
+
// Slide the idle window forward if (and only if) currently unlocked.
|
|
161
|
+
refreshActivity(gate.sessionId, getIdleSeconds(db), now().getTime());
|
|
162
|
+
return json(200, {
|
|
163
|
+
locked: isLockConfigured(db) && !isSessionUnlocked(gate.sessionId, now().getTime()),
|
|
164
|
+
unlock_seconds_remaining: unlockSecondsRemaining(gate.sessionId, now().getTime()),
|
|
165
|
+
});
|
|
166
|
+
default:
|
|
167
|
+
return err(404, "not_found", `no admin-lock route at /api/admin-lock${subpath}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Optional idle-seconds override from a request body field. Clamped; undefined when absent/invalid. */
|
|
172
|
+
function readIdleSeconds(body: Record<string, unknown>): number | undefined {
|
|
173
|
+
const raw = body.idle_seconds;
|
|
174
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
|
|
175
|
+
return clampIdleSeconds(raw);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function handleSet(
|
|
179
|
+
db: Database,
|
|
180
|
+
sessionId: string,
|
|
181
|
+
body: Record<string, unknown>,
|
|
182
|
+
): Promise<Response> {
|
|
183
|
+
// Setting the FIRST PIN — refuse if one already exists (use /change).
|
|
184
|
+
if (isLockConfigured(db)) {
|
|
185
|
+
return err(409, "already_configured", "a PIN is already set — use /api/admin-lock/change");
|
|
186
|
+
}
|
|
187
|
+
const pin = typeof body.pin === "string" ? body.pin : "";
|
|
188
|
+
if (!validatePin(pin).valid) {
|
|
189
|
+
return err(400, "invalid_pin", "PIN must be 4–12 digits");
|
|
190
|
+
}
|
|
191
|
+
const idle = readIdleSeconds(body);
|
|
192
|
+
if (idle !== undefined) setIdleSeconds(db, idle);
|
|
193
|
+
await setPin(db, pin);
|
|
194
|
+
// Setting the PIN immediately opens an unlock window for this session — the
|
|
195
|
+
// operator who just typed it isn't locked out of their own current session.
|
|
196
|
+
recordUnlock(sessionId, getIdleSeconds(db));
|
|
197
|
+
return json(201, { configured: true, locked: false, idle_seconds: getIdleSeconds(db) });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function handleChange(
|
|
201
|
+
db: Database,
|
|
202
|
+
sessionId: string,
|
|
203
|
+
body: Record<string, unknown>,
|
|
204
|
+
now: () => Date,
|
|
205
|
+
): Promise<Response> {
|
|
206
|
+
if (!isLockConfigured(db)) {
|
|
207
|
+
return err(409, "not_configured", "no PIN is set — use /api/admin-lock/set");
|
|
208
|
+
}
|
|
209
|
+
const newPin = typeof body.new_pin === "string" ? body.new_pin : "";
|
|
210
|
+
if (!validatePin(newPin).valid) {
|
|
211
|
+
return err(400, "invalid_pin", "new PIN must be 4–12 digits");
|
|
212
|
+
}
|
|
213
|
+
// Authorize the change: an already-unlocked session OR a correct current PIN.
|
|
214
|
+
const authorized = await authorizeMutation(db, sessionId, body, now);
|
|
215
|
+
if (!authorized.ok) return authorized.res;
|
|
216
|
+
const idle = readIdleSeconds(body);
|
|
217
|
+
if (idle !== undefined) setIdleSeconds(db, idle);
|
|
218
|
+
await setPin(db, newPin);
|
|
219
|
+
// Re-open the unlock window for this session under the new PIN.
|
|
220
|
+
recordUnlock(sessionId, getIdleSeconds(db), now().getTime());
|
|
221
|
+
return json(200, { configured: true, locked: false, idle_seconds: getIdleSeconds(db) });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function handleRemove(
|
|
225
|
+
db: Database,
|
|
226
|
+
sessionId: string,
|
|
227
|
+
body: Record<string, unknown>,
|
|
228
|
+
now: () => Date,
|
|
229
|
+
): Promise<Response> {
|
|
230
|
+
if (!isLockConfigured(db)) {
|
|
231
|
+
// Idempotent — already off.
|
|
232
|
+
return json(200, { configured: false, locked: false });
|
|
233
|
+
}
|
|
234
|
+
const authorized = await authorizeMutation(db, sessionId, body, now);
|
|
235
|
+
if (!authorized.ok) return authorized.res;
|
|
236
|
+
clearPin(db);
|
|
237
|
+
// Feature is off now — drop any unlock window (it no longer means anything).
|
|
238
|
+
lockSession(sessionId);
|
|
239
|
+
return json(200, { configured: false, locked: false });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Shared authorization for change/remove: succeed when the session is already
|
|
244
|
+
* unlocked, OR a correct `current_pin` is supplied. The current-PIN path runs
|
|
245
|
+
* through the same brute-force limiter as /unlock.
|
|
246
|
+
*/
|
|
247
|
+
async function authorizeMutation(
|
|
248
|
+
db: Database,
|
|
249
|
+
sessionId: string,
|
|
250
|
+
body: Record<string, unknown>,
|
|
251
|
+
now: () => Date,
|
|
252
|
+
): Promise<{ ok: true } | { ok: false; res: Response }> {
|
|
253
|
+
if (isSessionUnlocked(sessionId, now().getTime())) return { ok: true };
|
|
254
|
+
const currentPin = typeof body.current_pin === "string" ? body.current_pin : "";
|
|
255
|
+
if (!currentPin) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
res: err(
|
|
259
|
+
401,
|
|
260
|
+
"pin_required",
|
|
261
|
+
"this session is locked — supply current_pin (or unlock first)",
|
|
262
|
+
),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// Rate-limit BEFORE the argon2id verify (a stolen cookie shouldn't grind).
|
|
266
|
+
const rl = unlockLimiter.checkAndRecord(sessionId, now());
|
|
267
|
+
if (!rl.allowed) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
res: new Response(
|
|
271
|
+
JSON.stringify({
|
|
272
|
+
error: "too_many_attempts",
|
|
273
|
+
error_description: `too many PIN attempts — retry in ${rl.retryAfterSeconds}s`,
|
|
274
|
+
}),
|
|
275
|
+
{
|
|
276
|
+
status: 429,
|
|
277
|
+
headers: {
|
|
278
|
+
"content-type": "application/json",
|
|
279
|
+
"cache-control": "no-store",
|
|
280
|
+
"retry-after": String(rl.retryAfterSeconds ?? 60),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const ok = await verifyPin(db, currentPin);
|
|
287
|
+
if (!ok) return { ok: false, res: err(401, "wrong_pin", "incorrect PIN") };
|
|
288
|
+
return { ok: true };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function handleUnlock(
|
|
292
|
+
db: Database,
|
|
293
|
+
sessionId: string,
|
|
294
|
+
_userId: string,
|
|
295
|
+
body: Record<string, unknown>,
|
|
296
|
+
now: () => Date,
|
|
297
|
+
): Promise<Response> {
|
|
298
|
+
if (!isLockConfigured(db)) {
|
|
299
|
+
// No PIN → nothing to unlock; treat as already-open so the SPA proceeds.
|
|
300
|
+
return json(200, { unlocked: true, configured: false, idle_seconds: getIdleSeconds(db) });
|
|
301
|
+
}
|
|
302
|
+
const pin = typeof body.pin === "string" ? body.pin : "";
|
|
303
|
+
// Rate-limit BEFORE the argon2id verify — keyed by session (the session
|
|
304
|
+
// already identifies the actor; a stolen cookie shouldn't get fresh buckets
|
|
305
|
+
// across IPs). The denied attempt still counts toward the bucket so a wrong
|
|
306
|
+
// PIN can't be retried infinitely.
|
|
307
|
+
const rl = unlockLimiter.checkAndRecord(sessionId, now());
|
|
308
|
+
if (!rl.allowed) {
|
|
309
|
+
return new Response(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
error: "too_many_attempts",
|
|
312
|
+
error_description: `too many PIN attempts — retry in ${rl.retryAfterSeconds}s`,
|
|
313
|
+
}),
|
|
314
|
+
{
|
|
315
|
+
status: 429,
|
|
316
|
+
headers: {
|
|
317
|
+
"content-type": "application/json",
|
|
318
|
+
"cache-control": "no-store",
|
|
319
|
+
"retry-after": String(rl.retryAfterSeconds ?? 60),
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const ok = await verifyPin(db, pin);
|
|
325
|
+
if (!ok) {
|
|
326
|
+
return err(401, "wrong_pin", "incorrect PIN");
|
|
327
|
+
}
|
|
328
|
+
recordUnlock(sessionId, getIdleSeconds(db), now().getTime());
|
|
329
|
+
return json(200, {
|
|
330
|
+
unlocked: true,
|
|
331
|
+
configured: true,
|
|
332
|
+
idle_seconds: getIdleSeconds(db),
|
|
333
|
+
unlock_seconds_remaining: unlockSecondsRemaining(sessionId, now().getTime()),
|
|
334
|
+
});
|
|
335
|
+
}
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -286,8 +286,9 @@ interface PathMatch {
|
|
|
286
286
|
* for — but the gate is now "is this a KNOWN module" (`isKnownModuleShort`:
|
|
287
287
|
* KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old `CURATED_MODULES`
|
|
288
288
|
* whitelist (2026-06-09 modular-UI architecture, P2). This is what makes
|
|
289
|
-
* `POST /api/modules/
|
|
290
|
-
* running yet un-installable because it sat
|
|
289
|
+
* `POST /api/modules/agent/install` resolve — the agent module (then named
|
|
290
|
+
* channel) was discoverable + running yet un-installable because it sat
|
|
291
|
+
* outside the whitelist.
|
|
291
292
|
*
|
|
292
293
|
* The hub (`hub`) and genuinely third-party services.json rows still fall
|
|
293
294
|
* through to undefined: there's no install package to act on.
|
package/src/api-modules.ts
CHANGED
|
@@ -53,8 +53,8 @@ import {
|
|
|
53
53
|
shortNameForManifest,
|
|
54
54
|
} from "./service-spec.ts";
|
|
55
55
|
// `FIRST_PARTY_FALLBACKS` and `KNOWN_MODULES` are both consulted by
|
|
56
|
-
// `lookupModule` below — the former for notes
|
|
57
|
-
//
|
|
56
|
+
// `lookupModule` below — the former for notes (vendored manifest still
|
|
57
|
+
// required) and the latter for vault/scribe/runner/agent (post-FALLBACK
|
|
58
58
|
// retirement, hub#310). The local helper hides the split from the rest of
|
|
59
59
|
// this file. `discoverableShorts` enumerates their UNION — the
|
|
60
60
|
// self-registration-driven discovery surface that replaced the old
|
|
@@ -267,14 +267,14 @@ interface ModuleWireShape {
|
|
|
267
267
|
* `.parachute/module.json` `configUiUrl`, joined against its mount path the
|
|
268
268
|
* same way `management_url` resolves `managementUrl`/`uiUrl`. Drives the
|
|
269
269
|
* Modules page's consistent **Configure** action — clicking lands the
|
|
270
|
-
* operator on the module's own config UI (
|
|
270
|
+
* operator on the module's own config UI (agent `/agent/admin`, scribe
|
|
271
271
|
* `/scribe/admin`, …), which mints its admin Bearer from the hub's
|
|
272
|
-
* cookie-gated `/admin/module-token/<short>` (or `/admin/
|
|
272
|
+
* cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
|
|
273
273
|
*
|
|
274
274
|
* Null when the module hasn't declared `configUiUrl` — the SPA omits the
|
|
275
275
|
* Configure action for that module rather than rendering a dead button.
|
|
276
276
|
* Distinct from `management_url`: a module may declare one, both, or
|
|
277
|
-
* neither.
|
|
277
|
+
* neither. Agent declares `configUiUrl: "/agent/admin"` + `uiUrl`;
|
|
278
278
|
* vault declares `managementUrl` (its admin SPA is the config surface).
|
|
279
279
|
*/
|
|
280
280
|
config_ui_url: string | null;
|
|
@@ -493,8 +493,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
493
493
|
);
|
|
494
494
|
if (resolvedManagement !== undefined) managementUrlByShort.set(short, resolvedManagement);
|
|
495
495
|
// The config surface resolves with the SAME rule. A module may declare
|
|
496
|
-
// `configUiUrl` independently of `managementUrl` —
|
|
497
|
-
// `configUiUrl: "/
|
|
496
|
+
// `configUiUrl` independently of `managementUrl` — agent ships
|
|
497
|
+
// `configUiUrl: "/agent/admin"` (single-instance, origin-absolute)
|
|
498
498
|
// alongside a separate `uiUrl`.
|
|
499
499
|
const resolvedConfig = resolveModuleUrl(m.configUiUrl, value.mountPath, short);
|
|
500
500
|
if (resolvedConfig !== undefined) configUiUrlByShort.set(short, resolvedConfig);
|
|
@@ -744,8 +744,8 @@ let warnedLegacyVaultAdminCandidate = false;
|
|
|
744
744
|
* - `undefined` candidate → `undefined` (the module didn't declare it).
|
|
745
745
|
* - Absolute http(s) URL → returned verbatim (off-origin escape hatch).
|
|
746
746
|
* - Leading-`/` path → ORIGIN-ABSOLUTE, returned verbatim. Single-instance
|
|
747
|
-
* modules (surface, scribe, runner,
|
|
748
|
-
* path this way (`/surface/admin/`, `/scribe/admin`, `/
|
|
747
|
+
* modules (surface, scribe, runner, agent) declare their full hub-origin
|
|
748
|
+
* path this way (`/surface/admin/`, `/scribe/admin`, `/agent/admin`);
|
|
749
749
|
* vault's daemon-level surface is `/vault/admin/`.
|
|
750
750
|
* - Relative path (no leading slash) → MOUNT-JOINED: the per-instance form
|
|
751
751
|
* (`admin/` on a `/vault/default` mount → `/vault/default/admin/`). With
|
package/src/cli.ts
CHANGED
|
@@ -939,7 +939,19 @@ async function main(argv: string[]): Promise<number> {
|
|
|
939
939
|
// after `vault` (including --help) is passed through verbatim.
|
|
940
940
|
if (rest.length === 0) return await mod.dispatchVault(["--help"]);
|
|
941
941
|
|
|
942
|
-
//
|
|
942
|
+
// Intercept the delete verbs BEFORE the transparent passthrough (B3):
|
|
943
|
+
// `parachute vault remove <name>` must route through the hub's identity
|
|
944
|
+
// cascade (`DELETE /vaults/<name>`), NOT forward verbatim to
|
|
945
|
+
// `parachute-vault remove` (mechanics-only — orphans hub-side tokens,
|
|
946
|
+
// grants, user_vaults rows). `rm` is a convenience alias to the same path.
|
|
947
|
+
const sub = rest[0];
|
|
948
|
+
if (sub === "remove" || sub === "rm") {
|
|
949
|
+
const rm = await loadCommand("vault-remove", () => import("./commands/vault-remove.ts"));
|
|
950
|
+
if (!rm) return 1;
|
|
951
|
+
return await rm.vaultRemove(rest.slice(1));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Everything else under `vault` forwards transparently to `parachute-vault`.
|
|
943
955
|
// `vault tokens create` used to route through a guided interactive
|
|
944
956
|
// wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
|
|
945
957
|
// subcommand — it now exits 1 with migration guidance. Access tokens are
|
package/src/clients.ts
CHANGED
|
@@ -215,6 +215,94 @@ export function requireRegisteredRedirectUri(client: OAuthClient, candidate: str
|
|
|
215
215
|
return candidate;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Cross-hub-origin redirect_uri expansion for hub-served DCR clients
|
|
220
|
+
* (surface#118; the "hub assist" half of the fix).
|
|
221
|
+
*
|
|
222
|
+
* THE PROBLEM. A hub-served module (surface-host, notes) registers its OAuth
|
|
223
|
+
* client at install time, when the only origin it knows is the loopback hub
|
|
224
|
+
* URL (`http://127.0.0.1:1939`). Once the operator runs `parachute expose`,
|
|
225
|
+
* the browser loads the surface from the PUBLIC hub origin and the
|
|
226
|
+
* surface-client runtime computes its `redirect_uri` from `window.location.
|
|
227
|
+
* origin` (the public one) — which was never registered. Authorize-time
|
|
228
|
+
* matching is deliberately strict exact-match (`requireRegisteredRedirectUri`,
|
|
229
|
+
* RFC 8252 anti-open-redirect), so the public-origin redirect_uri is rejected
|
|
230
|
+
* ("Redirect mismatch") and no off-localhost user can sign in.
|
|
231
|
+
*
|
|
232
|
+
* THE FIX. At DCR time, for each submitted redirect_uri WHOSE ORIGIN IS ONE
|
|
233
|
+
* OF THE HUB'S OWN KNOWN ORIGINS (issuer, expose-state hubOrigin, platform
|
|
234
|
+
* origin, loopback aliases — i.e. the same set `buildHubBoundOrigins`
|
|
235
|
+
* produces), ALSO register the same PATH on every OTHER known hub origin. A
|
|
236
|
+
* loopback-registered hub-served client thus becomes valid on the public hub
|
|
237
|
+
* origin too, without ever loosening the strict authorize-time match.
|
|
238
|
+
*
|
|
239
|
+
* THE INVARIANT (no open redirect). Expansion ONLY ever produces URIs rooted
|
|
240
|
+
* at the hub's OWN known origins. A submitted redirect_uri whose origin is
|
|
241
|
+
* FOREIGN — a separate-origin surface on its own domain (e.g. my-vault-ui at
|
|
242
|
+
* `https://notes.example`), a third-party app — is stored VERBATIM: not
|
|
243
|
+
* expanded onto any hub origin, and not dropped. Only hub-origin-rooted URIs
|
|
244
|
+
* receive the cross-origin fan-out. Because authorize-time matching stays
|
|
245
|
+
* strict exact-match against this stored set, an attacker who registers a
|
|
246
|
+
* foreign redirect_uri gets back exactly what they submitted (no hub-origin
|
|
247
|
+
* variant minted for them), and a hub-origin-rooted URI only ever fans out to
|
|
248
|
+
* other hub origins the operator already controls.
|
|
249
|
+
*
|
|
250
|
+
* Order-preserving + de-duplicated: the originally-submitted URIs come first
|
|
251
|
+
* (preserving caller order), with the synthesized hub-origin variants
|
|
252
|
+
* appended in a stable order; duplicates are collapsed.
|
|
253
|
+
*/
|
|
254
|
+
export function expandRedirectUrisForHubOrigins(
|
|
255
|
+
submitted: readonly string[],
|
|
256
|
+
hubOrigins: readonly string[],
|
|
257
|
+
): string[] {
|
|
258
|
+
// Parse the hub's known origins into a Set for membership tests. Malformed
|
|
259
|
+
// entries are skipped — the function fails safe (no expansion) rather than
|
|
260
|
+
// throwing.
|
|
261
|
+
const hubOriginSet = new Set<string>();
|
|
262
|
+
for (const raw of hubOrigins) {
|
|
263
|
+
try {
|
|
264
|
+
hubOriginSet.add(new URL(raw).origin);
|
|
265
|
+
} catch {
|
|
266
|
+
// Skip malformed hub origin.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Preserve submitted order + dedupe; append synthesized variants after.
|
|
271
|
+
const out: string[] = [];
|
|
272
|
+
const seen = new Set<string>();
|
|
273
|
+
const push = (uri: string) => {
|
|
274
|
+
if (seen.has(uri)) return;
|
|
275
|
+
seen.add(uri);
|
|
276
|
+
out.push(uri);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// 1) Every submitted URI is stored as-is (foreign + hub-origin alike). This
|
|
280
|
+
// is what guarantees a foreign redirect_uri is never dropped.
|
|
281
|
+
for (const uri of submitted) push(uri);
|
|
282
|
+
|
|
283
|
+
// 2) For each submitted URI rooted at a hub origin, synthesize the same path
|
|
284
|
+
// on every OTHER hub origin. Foreign-origin URIs are skipped here — they
|
|
285
|
+
// never spawn a hub-origin variant (the open-redirect guard).
|
|
286
|
+
if (hubOriginSet.size > 1) {
|
|
287
|
+
for (const uri of submitted) {
|
|
288
|
+
let parsed: URL;
|
|
289
|
+
try {
|
|
290
|
+
parsed = new URL(uri);
|
|
291
|
+
} catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (!hubOriginSet.has(parsed.origin)) continue; // foreign → no expansion
|
|
295
|
+
const pathSuffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
296
|
+
for (const origin of hubOriginSet) {
|
|
297
|
+
if (origin === parsed.origin) continue;
|
|
298
|
+
push(`${origin}${pathSuffix}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
218
306
|
export function verifyClientSecret(client: OAuthClient, presented: string): boolean {
|
|
219
307
|
if (!client.clientSecretHash) return false;
|
|
220
308
|
const presentedHash = createHash("sha256").update(presented).digest("hex");
|
package/src/commands/install.ts
CHANGED
|
@@ -125,9 +125,16 @@ export function resolveInstallChannel(opts: {
|
|
|
125
125
|
* install lens` during the ~3-day window keeps working. Remove after
|
|
126
126
|
* launch sinks in and `parachute install lens` has stopped appearing
|
|
127
127
|
* in support threads.
|
|
128
|
+
*
|
|
129
|
+
* `channel → agent` (2026-06-17): parachute-channel was renamed to
|
|
130
|
+
* parachute-agent. `parachute install channel` keeps resolving to the
|
|
131
|
+
* agent module for one release cycle so operators mid-upgrade aren't
|
|
132
|
+
* stranded. Remove alongside the `parachute-channel → agent` legacy
|
|
133
|
+
* manifest alias in service-spec.ts.
|
|
128
134
|
*/
|
|
129
135
|
const SERVICE_ALIASES: Record<string, string> = {
|
|
130
136
|
lens: "notes",
|
|
137
|
+
channel: "agent",
|
|
131
138
|
};
|
|
132
139
|
|
|
133
140
|
export interface InstallOpts {
|
|
@@ -78,12 +78,13 @@ export interface BuildSpawnRequestOpts {
|
|
|
78
78
|
*
|
|
79
79
|
* Why (channel#41): the hub is the port authority, and a first-party module
|
|
80
80
|
* with a compiled-in canonical port (`canonicalPortForManifest` ≠ undefined —
|
|
81
|
-
* vault / scribe / surface /
|
|
81
|
+
* vault / scribe / surface / agent / notes / runner) has exactly ONE correct
|
|
82
82
|
* port: its canonical one. The injected `PORT`, the supervisor's readiness
|
|
83
83
|
* probe, and the reverse-proxy target are ALL derived from the services.json
|
|
84
|
-
* row's `port`. So once a transiently-wrong port lands in that row (the
|
|
85
|
-
* row was observed live carrying `19415` instead of
|
|
86
|
-
*
|
|
84
|
+
* row's `port`. So once a transiently-wrong port lands in that row (the agent
|
|
85
|
+
* row — then named channel — was observed live carrying `19415` instead of
|
|
86
|
+
* `1941`), it self-perpetuates:
|
|
87
|
+
* the supervisor probes/proxies the wrong port forever and `/agent/*` routes
|
|
87
88
|
* to a dead port. The canonical port is authoritative, so we reconcile the row
|
|
88
89
|
* before spawn rather than honoring the drift.
|
|
89
90
|
*
|
package/src/commands/serve.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../h
|
|
|
38
38
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
39
39
|
import { hubFetch } from "../hub-server.ts";
|
|
40
40
|
import { writeHubFile } from "../hub.ts";
|
|
41
|
+
import { createWsBridgeHandlers } from "../ws-bridge.ts";
|
|
41
42
|
import { enrichedPath } from "../spawn-path.ts";
|
|
42
43
|
import { Supervisor } from "../supervisor.ts";
|
|
43
44
|
import { createUser, userCount } from "../users.ts";
|
|
@@ -45,6 +46,35 @@ import { sanitizePublicOrigin } from "../vault-hub-origin-env.ts";
|
|
|
45
46
|
import { WELL_KNOWN_DIR } from "../well-known.ts";
|
|
46
47
|
import { bootSupervisedModules } from "./serve-boot.ts";
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Build the `Bun.serve` options for the hub listener. Extracted (pure) so the
|
|
51
|
+
* load-bearing wiring — crucially the `websocket` bridge handler — is unit-
|
|
52
|
+
* testable and can't silently drift from the other `Bun.serve` in
|
|
53
|
+
* `hub-server.ts`. That drift is exactly how the in-page-terminal WS upgrade
|
|
54
|
+
* started 500ing: the bridge was wired into hub-server.ts's serve but NOT this
|
|
55
|
+
* production `parachute serve` path, so `server.upgrade()` (in `hubFetch`'s
|
|
56
|
+
* `maybeUpgradeWebSocket`) threw "set the websocket object in Bun.serve({})".
|
|
57
|
+
*/
|
|
58
|
+
export function hubServeOptions(args: {
|
|
59
|
+
port: number;
|
|
60
|
+
hostname: string;
|
|
61
|
+
fetch: ReturnType<typeof hubFetch>;
|
|
62
|
+
}) {
|
|
63
|
+
return {
|
|
64
|
+
port: args.port,
|
|
65
|
+
hostname: args.hostname,
|
|
66
|
+
// Hold idle keep-alive connections for Bun's maximum 255s so reverse-proxy
|
|
67
|
+
// edges (Render, Cloudflare, fly.io) don't race us reusing pooled
|
|
68
|
+
// connections (hub#399).
|
|
69
|
+
idleTimeout: 255,
|
|
70
|
+
fetch: args.fetch,
|
|
71
|
+
// The WebSocket upgrade bridge. `maybeUpgradeWebSocket` (in `hubFetch`) calls
|
|
72
|
+
// `server.upgrade()`, which THROWS unless the server declares this handler —
|
|
73
|
+
// so it MUST be present on every Bun.serve that runs `hubFetch`.
|
|
74
|
+
websocket: createWsBridgeHandlers(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
export interface ServeOpts {
|
|
49
79
|
/** Override PORT (test-only). Real callers thread env via process.env. */
|
|
50
80
|
port?: number;
|
|
@@ -456,26 +486,22 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
456
486
|
// fail fast and cleanly, leaving the live hub's children untouched.
|
|
457
487
|
let server: ReturnType<typeof Bun.serve>;
|
|
458
488
|
try {
|
|
459
|
-
server = Bun.serve(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
probeDbPath: () => dbHolder.probePath(),
|
|
474
|
-
issuer,
|
|
475
|
-
loopbackPort: port,
|
|
476
|
-
supervisor,
|
|
489
|
+
server = Bun.serve(
|
|
490
|
+
hubServeOptions({
|
|
491
|
+
port,
|
|
492
|
+
hostname,
|
|
493
|
+
fetch: hubFetch(WELL_KNOWN_DIR, {
|
|
494
|
+
getDb: () => dbHolder.get(),
|
|
495
|
+
onDbError: (err) => dbHolder.healOrExit(err),
|
|
496
|
+
// #610: /health's db check probes the path so monitoring + the #591
|
|
497
|
+
// adoption probe see a wipe instead of the ghost-fd lie.
|
|
498
|
+
probeDbPath: () => dbHolder.probePath(),
|
|
499
|
+
issuer,
|
|
500
|
+
loopbackPort: port,
|
|
501
|
+
supervisor,
|
|
502
|
+
}),
|
|
477
503
|
}),
|
|
478
|
-
|
|
504
|
+
);
|
|
479
505
|
} catch (err) {
|
|
480
506
|
const conflict = hubPortConflictMessage(err, port);
|
|
481
507
|
if (conflict) throw new Error(conflict);
|
package/src/commands/setup.ts
CHANGED
|
@@ -109,13 +109,13 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* Survey the eligible services. We include the four first-party shortnames
|
|
112
|
-
* (vault / notes / scribe /
|
|
112
|
+
* (vault / notes / scribe / agent + runner) but flag agent as exploratory
|
|
113
113
|
* in the blurb so operators don't grab it by reflex. `installed` is true when
|
|
114
114
|
* the service has a row in services.json.
|
|
115
115
|
*
|
|
116
116
|
* The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
|
|
117
117
|
* shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
|
|
118
|
-
* (vault / scribe / runner /
|
|
118
|
+
* (vault / scribe / runner / agent / surface) ship `.parachute/module.json`
|
|
119
119
|
* and self-register; pre-install we know manifestName + the urlForEntry quirk
|
|
120
120
|
* from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
|
|
121
121
|
*/
|
|
@@ -155,7 +155,8 @@ const BLURBS: Record<string, string> = {
|
|
|
155
155
|
notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `app`)",
|
|
156
156
|
scribe: "audio transcription for dictation + recordings",
|
|
157
157
|
runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
|
|
158
|
-
|
|
158
|
+
agent:
|
|
159
|
+
"(exploratory) chat with your Claude Code sessions — a channel per session (renamed from channel)",
|
|
159
160
|
};
|
|
160
161
|
|
|
161
162
|
function blurbFor(choice: ServiceChoice): string {
|