@openparachute/hub 0.7.1 → 0.7.2-rc.1
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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional idle screen-lock for the hub ADMIN UI (phone-style lock).
|
|
3
|
+
*
|
|
4
|
+
* ## What this guards (and what it deliberately doesn't)
|
|
5
|
+
*
|
|
6
|
+
* The hub admin UI is reachable remotely once `parachute expose` is up
|
|
7
|
+
* (Tailscale / Cloudflare). A grabbed or left-logged-in admin browser session
|
|
8
|
+
* is a real risk: the password-login session cookie lasts 24h, so an
|
|
9
|
+
* unattended tab is a standing admin console. This adds an OPTIONAL operator
|
|
10
|
+
* PIN that LOCKS THE WHOLE ADMIN SURFACE — one lock over everything, not
|
|
11
|
+
* per-action gating (per-action would friction-up configuration, which is the
|
|
12
|
+
* dangerous stuff we most want behind the lock anyway). Unlock with the PIN →
|
|
13
|
+
* a frictionless working window; admin activity refreshes it; idle re-locks.
|
|
14
|
+
*
|
|
15
|
+
* THREAT MODEL: this is a WEB/UI-layer guard for the EXPOSED admin portal. It
|
|
16
|
+
* does NOT protect against someone with a SHELL on the box — they read
|
|
17
|
+
* `~/.parachute/operator.token` / the vault DB directly and bypass the hub
|
|
18
|
+
* entirely. That's an OS concern (disk encryption, a locked OS screen, SSH-key
|
|
19
|
+
* hygiene). The lock shuts the *portal* door — the one the internet can reach —
|
|
20
|
+
* which is the point.
|
|
21
|
+
*
|
|
22
|
+
* ## The single chokepoint
|
|
23
|
+
*
|
|
24
|
+
* The admin SPA + every module config UI get their working Bearer from one of
|
|
25
|
+
* the cookie-gated mint endpoints:
|
|
26
|
+
* - `GET /admin/host-admin-token` (the SPA's own Bearer)
|
|
27
|
+
* - `GET /admin/agent-token` (agent chat + config UIs)
|
|
28
|
+
* - `GET /admin/vault-admin-token/<name>`(per-vault admin SPA)
|
|
29
|
+
* - `GET /admin/module-token/<short>` (generic module config UI Bearer)
|
|
30
|
+
*
|
|
31
|
+
* All four share the exact `parseSessionCookie → findSession → [isFirstAdmin]
|
|
32
|
+
* → signAccessToken` shape. Inserting {@link requireUnlocked} into each makes
|
|
33
|
+
* the lock cascade to EVERY admin surface with no per-module changes: when
|
|
34
|
+
* locked, the mint returns 423 and the relevant UI shows the lock screen / its
|
|
35
|
+
* admin calls fail closed (no Bearer). A surface OAuth'ing in via `/oauth/*`
|
|
36
|
+
* never touches these endpoints, so the OAuth issuer is untouched — see the
|
|
37
|
+
* design note (`design/2026-06-17-admin-ui-lock.md`).
|
|
38
|
+
*
|
|
39
|
+
* ## Unlock state — per-session, in-memory, "unlocked-until"
|
|
40
|
+
*
|
|
41
|
+
* Simplest workable model. A successful PIN unlock records `unlockedUntil` for
|
|
42
|
+
* the session id (the cookie value) in a process-local Map. The session is
|
|
43
|
+
* UNLOCKED iff a future `unlockedUntil` exists; genuine USER activity (the SPA's
|
|
44
|
+
* debounced `/heartbeat`, fired on pointer/key/scroll) slides it forward by the
|
|
45
|
+
* idle window. Token mints do NOT slide it — the SPA polls some endpoints in the
|
|
46
|
+
* background, and letting a poll's re-mint extend the window would keep an
|
|
47
|
+
* idle-but-open tab unlocked forever. LOCKED when:
|
|
48
|
+
* - no PIN is set → feature OFF, never locked (today's behavior);
|
|
49
|
+
* - PIN set + no unlock recorded (fresh load / first visit);
|
|
50
|
+
* - PIN set + the recorded unlock is in the past (idle-expired);
|
|
51
|
+
* - a hub restart wipes the Map → re-lock (a feature, not a bug).
|
|
52
|
+
*
|
|
53
|
+
* The unlock state is NEVER persisted and NEVER put in the cookie — a stolen
|
|
54
|
+
* cookie alone can't carry an unlocked window; the attacker still needs the
|
|
55
|
+
* PIN. (The cookie already authenticates the password session; the PIN is the
|
|
56
|
+
* second, idle-bounded gate on top.)
|
|
57
|
+
*/
|
|
58
|
+
import type { Database } from "bun:sqlite";
|
|
59
|
+
import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
|
|
60
|
+
import { deleteSetting, getSetting, setSetting } from "./hub-settings.ts";
|
|
61
|
+
import { RateLimiter } from "./rate-limit.ts";
|
|
62
|
+
|
|
63
|
+
/** Default idle window before a session re-locks. Phone-lock territory. */
|
|
64
|
+
export const DEFAULT_ADMIN_LOCK_IDLE_SECONDS = 15 * 60;
|
|
65
|
+
|
|
66
|
+
/** Clamp on the operator-configurable idle window: 1 min … 24h. */
|
|
67
|
+
export const MIN_ADMIN_LOCK_IDLE_SECONDS = 60;
|
|
68
|
+
export const MAX_ADMIN_LOCK_IDLE_SECONDS = 24 * 60 * 60;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* PIN format: 4–12 digits. A numeric PIN is the phone-lock affordance the
|
|
72
|
+
* feature is named for; the real defense is the idle window + brute-force
|
|
73
|
+
* limiter, not PIN entropy (the session is already password-authenticated —
|
|
74
|
+
* this is a second, convenience-grade gate). Operators wanting a full secret
|
|
75
|
+
* use the OS lock / a strong account password; this is the "don't leave the
|
|
76
|
+
* console open on the train" guard.
|
|
77
|
+
*/
|
|
78
|
+
export const ADMIN_LOCK_PIN_RE = /^[0-9]{4,12}$/;
|
|
79
|
+
|
|
80
|
+
export type ValidatePinResult = { valid: true } | { valid: false; reason: "format" };
|
|
81
|
+
|
|
82
|
+
export function validatePin(pin: string): ValidatePinResult {
|
|
83
|
+
return ADMIN_LOCK_PIN_RE.test(pin) ? { valid: true } : { valid: false, reason: "format" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- PIN storage (argon2id hash in hub_settings) ---------------------------
|
|
87
|
+
|
|
88
|
+
/** True iff an admin-lock PIN is configured (feature is ON). */
|
|
89
|
+
export function isLockConfigured(db: Database): boolean {
|
|
90
|
+
const h = getSetting(db, "admin_lock_pin_hash");
|
|
91
|
+
return typeof h === "string" && h.length > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Store (or rotate) the PIN. Hashes with argon2id — the same family the hub
|
|
96
|
+
* already uses for passwords + TOTP backup codes (`@node-rs/argon2`). The
|
|
97
|
+
* caller MUST have validated the PIN format first; this trusts its input.
|
|
98
|
+
*/
|
|
99
|
+
export async function setPin(db: Database, pin: string): Promise<void> {
|
|
100
|
+
const h = await argonHash(pin);
|
|
101
|
+
setSetting(db, "admin_lock_pin_hash", h);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remove the PIN (turn the feature OFF). Idempotent. Also clears idle config. */
|
|
105
|
+
export function clearPin(db: Database): void {
|
|
106
|
+
deleteSetting(db, "admin_lock_pin_hash");
|
|
107
|
+
deleteSetting(db, "admin_lock_idle_seconds");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Verify a submitted PIN against the stored hash. Returns false when no PIN is
|
|
112
|
+
* configured (defensive — callers gate on {@link isLockConfigured} first) or
|
|
113
|
+
* the hash is malformed.
|
|
114
|
+
*/
|
|
115
|
+
export async function verifyPin(db: Database, pin: string): Promise<boolean> {
|
|
116
|
+
const h = getSetting(db, "admin_lock_pin_hash");
|
|
117
|
+
if (typeof h !== "string" || h.length === 0) return false;
|
|
118
|
+
try {
|
|
119
|
+
return await argonVerify(h, pin);
|
|
120
|
+
} catch {
|
|
121
|
+
// Corrupt/unparseable hash — fail closed.
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- idle window config ----------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Read the configured idle window (clamped). Falls back to the default. */
|
|
129
|
+
export function getIdleSeconds(db: Database): number {
|
|
130
|
+
const raw = getSetting(db, "admin_lock_idle_seconds");
|
|
131
|
+
if (typeof raw !== "string") return DEFAULT_ADMIN_LOCK_IDLE_SECONDS;
|
|
132
|
+
const n = Number.parseInt(raw, 10);
|
|
133
|
+
if (!Number.isFinite(n)) return DEFAULT_ADMIN_LOCK_IDLE_SECONDS;
|
|
134
|
+
return clampIdleSeconds(n);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function clampIdleSeconds(n: number): number {
|
|
138
|
+
if (n < MIN_ADMIN_LOCK_IDLE_SECONDS) return MIN_ADMIN_LOCK_IDLE_SECONDS;
|
|
139
|
+
if (n > MAX_ADMIN_LOCK_IDLE_SECONDS) return MAX_ADMIN_LOCK_IDLE_SECONDS;
|
|
140
|
+
return Math.floor(n);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Persist the operator's idle-window choice (clamped). */
|
|
144
|
+
export function setIdleSeconds(db: Database, seconds: number): void {
|
|
145
|
+
setSetting(db, "admin_lock_idle_seconds", String(clampIdleSeconds(seconds)));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- per-session unlock state (in-memory, never persisted) -----------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* sessionId → unlockedUntil epoch ms. Process-local; a restart wipes it (which
|
|
152
|
+
* is the re-lock-on-fresh-process behavior we want). Bounded by an
|
|
153
|
+
* opportunistic prune of expired rows on every touch, so an attacker churning
|
|
154
|
+
* session ids can't grow it unboundedly.
|
|
155
|
+
*/
|
|
156
|
+
const unlockedUntil = new Map<string, number>();
|
|
157
|
+
|
|
158
|
+
function pruneExpired(now: number): void {
|
|
159
|
+
for (const [sid, until] of unlockedUntil) {
|
|
160
|
+
if (until <= now) unlockedUntil.delete(sid);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Record a fresh unlock for `sessionId`, valid for `idleSeconds` from now.
|
|
166
|
+
* Called after a successful PIN verify.
|
|
167
|
+
*/
|
|
168
|
+
export function recordUnlock(
|
|
169
|
+
sessionId: string,
|
|
170
|
+
idleSeconds: number,
|
|
171
|
+
now: number = Date.now(),
|
|
172
|
+
): void {
|
|
173
|
+
pruneExpired(now);
|
|
174
|
+
unlockedUntil.set(sessionId, now + idleSeconds * 1000);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Slide the unlock window forward by `idleSeconds` IF the session is currently
|
|
179
|
+
* unlocked. Called on admin activity (heartbeat + every successful mint) so an
|
|
180
|
+
* actively-used console doesn't lock mid-work. Does NOT create an unlock for a
|
|
181
|
+
* locked session — only an explicit PIN entry can do that.
|
|
182
|
+
*/
|
|
183
|
+
export function refreshActivity(
|
|
184
|
+
sessionId: string,
|
|
185
|
+
idleSeconds: number,
|
|
186
|
+
now: number = Date.now(),
|
|
187
|
+
): void {
|
|
188
|
+
const until = unlockedUntil.get(sessionId);
|
|
189
|
+
if (until === undefined || until <= now) return; // locked — activity doesn't unlock
|
|
190
|
+
unlockedUntil.set(sessionId, now + idleSeconds * 1000);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Is this session currently within an unlock window? */
|
|
194
|
+
export function isSessionUnlocked(sessionId: string, now: number = Date.now()): boolean {
|
|
195
|
+
const until = unlockedUntil.get(sessionId);
|
|
196
|
+
return until !== undefined && until > now;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Force-lock a session immediately ("Lock now"). Idempotent. */
|
|
200
|
+
export function lockSession(sessionId: string): void {
|
|
201
|
+
unlockedUntil.delete(sessionId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Remaining unlock seconds for a session (0 when locked). For status UI. */
|
|
205
|
+
export function unlockSecondsRemaining(sessionId: string, now: number = Date.now()): number {
|
|
206
|
+
const until = unlockedUntil.get(sessionId);
|
|
207
|
+
if (until === undefined || until <= now) return 0;
|
|
208
|
+
return Math.ceil((until - now) / 1000);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Test seam: wipe all in-memory unlock state. */
|
|
212
|
+
export function _resetUnlockStateForTest(): void {
|
|
213
|
+
unlockedUntil.clear();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- the gate the four token-mint chokepoints call -------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The single lock gate. Returns whether the mint may proceed.
|
|
220
|
+
*
|
|
221
|
+
* - feature OFF (no PIN) → ALWAYS allow (today's behavior, byte-for-byte).
|
|
222
|
+
* - feature ON + session unlocked → allow.
|
|
223
|
+
* - feature ON + session locked → DENY (the caller returns 423 Locked).
|
|
224
|
+
*
|
|
225
|
+
* Deliberately a PURE CHECK — it does NOT slide the idle window. Sliding is
|
|
226
|
+
* driven exclusively by genuine USER activity (the SPA's debounced
|
|
227
|
+
* `/heartbeat`, fired on pointer/key/scroll), NOT by token mints. The SPA polls
|
|
228
|
+
* some endpoints in the background (e.g. the version badge every 30s), and each
|
|
229
|
+
* such poll re-mints a host-admin Bearer; if a mint extended the window, a
|
|
230
|
+
* left-open-but-idle tab would never lock — defeating the whole feature. The
|
|
231
|
+
* heartbeat is the one thing that means "a human is here."
|
|
232
|
+
*
|
|
233
|
+
* `now` is injectable for tests.
|
|
234
|
+
*/
|
|
235
|
+
export function requireUnlocked(
|
|
236
|
+
db: Database,
|
|
237
|
+
sessionId: string,
|
|
238
|
+
now: number = Date.now(),
|
|
239
|
+
): { ok: true } | { ok: false; reason: "locked" } {
|
|
240
|
+
if (!isLockConfigured(db)) return { ok: true };
|
|
241
|
+
if (isSessionUnlocked(sessionId, now)) {
|
|
242
|
+
return { ok: true };
|
|
243
|
+
}
|
|
244
|
+
return { ok: false, reason: "locked" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** RFC-shaped 423 the mint chokepoints return when locked. */
|
|
248
|
+
export function lockedResponse(): Response {
|
|
249
|
+
return new Response(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
error: "locked",
|
|
252
|
+
error_description:
|
|
253
|
+
"the admin UI is locked — enter your PIN to unlock (POST /api/admin-lock/unlock)",
|
|
254
|
+
}),
|
|
255
|
+
{
|
|
256
|
+
status: 423,
|
|
257
|
+
headers: {
|
|
258
|
+
"content-type": "application/json",
|
|
259
|
+
"cache-control": "no-store",
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- unlock brute-force limiter --------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Per-session unlock-attempt limiter. The unlock endpoint is session-gated, so
|
|
269
|
+
* the threat is a compromised session (stolen cookie) grinding argon2id PIN
|
|
270
|
+
* verifications without bound — keyed by session id, same posture as
|
|
271
|
+
* `/account/change-password` (keyed by user). 5 wrong PINs / 5 min is the
|
|
272
|
+
* floor; the idle window + the limiter together are the real defense (the PIN
|
|
273
|
+
* itself is convenience-grade — see the module header).
|
|
274
|
+
*/
|
|
275
|
+
export const ADMIN_LOCK_UNLOCK_MAX_ATTEMPTS = 5;
|
|
276
|
+
export const ADMIN_LOCK_UNLOCK_WINDOW_MS = 5 * 60 * 1000;
|
|
277
|
+
|
|
278
|
+
export const unlockLimiter = new RateLimiter(
|
|
279
|
+
ADMIN_LOCK_UNLOCK_MAX_ATTEMPTS,
|
|
280
|
+
ADMIN_LOCK_UNLOCK_WINDOW_MS,
|
|
281
|
+
);
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
* hub frames/links those surfaces consistently (the Modules page "Configure"
|
|
9
9
|
* action). Each module-owned config UI, served behind the hub proxy to a
|
|
10
10
|
* logged-in portal operator, needs an admin-scoped hub Bearer to call its own
|
|
11
|
-
* `<short>:admin`-gated endpoints — the same shape the
|
|
12
|
-
* from `/admin/
|
|
11
|
+
* `<short>:admin`-gated endpoints — the same shape the agent config UI gets
|
|
12
|
+
* from `/admin/agent-token` and the vault admin SPA gets from
|
|
13
13
|
* `/admin/vault-admin-token/<name>`. This is the GENERIC mint that covers every
|
|
14
14
|
* other self-registered single-audience module, so the hub doesn't grow a
|
|
15
15
|
* bespoke per-module mint endpoint as each module ships a config UI.
|
|
16
16
|
*
|
|
17
17
|
* Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
|
|
18
18
|
* prefix). Modules validate the JWT's `aud` against their literal short name
|
|
19
|
-
* (`scribe`, `runner`, `surface`, `
|
|
19
|
+
* (`scribe`, `runner`, `surface`, `agent`) — the same shape `inferAudience`
|
|
20
20
|
* stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
|
|
21
21
|
* token are indistinguishable to the module. This mirrors the per-request
|
|
22
22
|
* `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
|
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
*
|
|
33
33
|
* Gate: the session must belong to the first admin (the single hub admin under
|
|
34
34
|
* the Phase 1 multi-user model — `users.ts:isFirstAdmin`), exactly like
|
|
35
|
-
* host-admin-token / vault-admin-token /
|
|
35
|
+
* host-admin-token / vault-admin-token / agent-token. A friend account holds
|
|
36
36
|
* a valid session but must not mint a module admin Bearer.
|
|
37
37
|
*
|
|
38
38
|
* Tokens are short-lived (10 min — matches the sibling admin-token mints); the
|
|
39
39
|
* config UI re-fetches on near-expiry.
|
|
40
40
|
*/
|
|
41
41
|
import type { Database } from "bun:sqlite";
|
|
42
|
+
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
42
43
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
43
44
|
import {
|
|
44
45
|
type ModuleManifest,
|
|
@@ -49,7 +50,7 @@ import type { ServiceEntry } from "./services-manifest.ts";
|
|
|
49
50
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
50
51
|
import { isFirstAdmin } from "./users.ts";
|
|
51
52
|
|
|
52
|
-
/** Short TTL — matches host/vault/
|
|
53
|
+
/** Short TTL — matches host/vault/agent admin-token. UI re-fetches on near-expiry. */
|
|
53
54
|
export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
|
|
54
55
|
const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
|
|
55
56
|
|
|
@@ -143,10 +144,10 @@ export async function handleModuleToken(
|
|
|
143
144
|
}
|
|
144
145
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
145
146
|
const session = sid ? findSession(deps.db, sid) : null;
|
|
146
|
-
if (!session) {
|
|
147
|
+
if (!session || !sid) {
|
|
147
148
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
148
149
|
}
|
|
149
|
-
// First-admin gate (mirrors host/vault/
|
|
150
|
+
// First-admin gate (mirrors host/vault/agent-admin-token). A friend account
|
|
150
151
|
// (non-first-admin user) holds a valid session but must not mint a module
|
|
151
152
|
// admin Bearer.
|
|
152
153
|
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
@@ -156,6 +157,13 @@ export async function handleModuleToken(
|
|
|
156
157
|
"module admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
157
158
|
);
|
|
158
159
|
}
|
|
160
|
+
// Admin screen-lock gate (see admin-host-admin-token.ts). A locked admin
|
|
161
|
+
// session can't mint a module admin Bearer, so every module-owned config UI
|
|
162
|
+
// fails closed until the operator unlocks. This is what makes the lock
|
|
163
|
+
// cascade to ALL modules with zero per-module changes. Off by default.
|
|
164
|
+
if (!requireUnlocked(deps.db, sid).ok) {
|
|
165
|
+
return lockedResponse();
|
|
166
|
+
}
|
|
159
167
|
const scope = `${short}:admin`;
|
|
160
168
|
const minted = await signAccessToken(deps.db, {
|
|
161
169
|
sub: session.userId,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* for their assigned vault; they don't get vault admin via this endpoint.
|
|
26
26
|
*/
|
|
27
27
|
import type { Database } from "bun:sqlite";
|
|
28
|
+
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
28
29
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
29
30
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
30
31
|
import { isFirstAdmin } from "./users.ts";
|
|
@@ -66,7 +67,7 @@ export async function handleVaultAdminToken(
|
|
|
66
67
|
}
|
|
67
68
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
68
69
|
const session = sid ? findSession(deps.db, sid) : null;
|
|
69
|
-
if (!session) {
|
|
70
|
+
if (!session || !sid) {
|
|
70
71
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
71
72
|
}
|
|
72
73
|
// Multi-user Phase 1 privesc gate (mirrors host-admin-token). vault:<name>:admin
|
|
@@ -80,6 +81,12 @@ export async function handleVaultAdminToken(
|
|
|
80
81
|
"vault admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
81
82
|
);
|
|
82
83
|
}
|
|
84
|
+
// Admin screen-lock gate (see admin-host-admin-token.ts). A locked admin
|
|
85
|
+
// session can't mint a vault admin Bearer, so the vault admin SPA fails
|
|
86
|
+
// closed until the operator unlocks. Off by default.
|
|
87
|
+
if (!requireUnlocked(deps.db, sid).ok) {
|
|
88
|
+
return lockedResponse();
|
|
89
|
+
}
|
|
83
90
|
const scope = `vault:${vaultName}:admin`;
|
|
84
91
|
// Per-vault audience: vault validates the JWT's `aud` claim against
|
|
85
92
|
// `vault.<name>` derived from its own URL-bound config (vault src/auth.ts
|
package/src/admin-vaults.ts
CHANGED
|
@@ -537,8 +537,8 @@ export interface DeleteVaultDeps {
|
|
|
537
537
|
manifestPath?: string;
|
|
538
538
|
/** Absolute path to `connections.json` in the hub state dir. */
|
|
539
539
|
connectionsStorePath: string;
|
|
540
|
-
/** Loopback origin for the
|
|
541
|
-
|
|
540
|
+
/** Loopback origin for the agent daemon, or `null` when not installed. */
|
|
541
|
+
agentOrigin: string | null;
|
|
542
542
|
/** Resolve a vault's loopback origin from services.json (trigger teardown). */
|
|
543
543
|
resolveVaultOrigin: (vaultName: string) => string | null;
|
|
544
544
|
/**
|
|
@@ -651,7 +651,7 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
651
651
|
* the name);
|
|
652
652
|
* 5. connections whose source/provisioned vault is the deleted vault
|
|
653
653
|
* (via `teardownConnection`, which post-B0 also revokes the registered
|
|
654
|
-
* long-lived mints) + a report-only scan of
|
|
654
|
+
* long-lived mints) + a report-only scan of the agent's `/api/channels`
|
|
655
655
|
* for legacy vault-backed entries (`orphaned_channels`);
|
|
656
656
|
* 6. mechanics: shell to `parachute-vault remove <name> --yes` (the module
|
|
657
657
|
* CLI stays the single source of truth for vault destruction,
|
|
@@ -764,7 +764,7 @@ export async function handleDeleteVault(
|
|
|
764
764
|
...(deps.resolveModuleOrigin !== undefined
|
|
765
765
|
? { resolveModuleOrigin: deps.resolveModuleOrigin }
|
|
766
766
|
: {}),
|
|
767
|
-
|
|
767
|
+
agentOrigin: deps.agentOrigin,
|
|
768
768
|
storePath: deps.connectionsStorePath,
|
|
769
769
|
...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
|
|
770
770
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
@@ -797,9 +797,9 @@ export async function handleDeleteVault(
|
|
|
797
797
|
|
|
798
798
|
// Legacy channel scan — vault-backed channel entries still referencing the
|
|
799
799
|
// vault after connection teardown (pre-Connections wiring). REPORT ONLY:
|
|
800
|
-
// channel
|
|
801
|
-
// them from
|
|
802
|
-
if (deps.
|
|
800
|
+
// the channel config is the agent module's domain; the operator removes
|
|
801
|
+
// them from the agent's own UI.
|
|
802
|
+
if (deps.agentOrigin !== null) {
|
|
803
803
|
try {
|
|
804
804
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
805
805
|
// Short-lived (60s) — stays below the registered-mint threshold by
|
|
@@ -808,8 +808,8 @@ export async function handleDeleteVault(
|
|
|
808
808
|
const scanToken = (
|
|
809
809
|
await signAccessToken(deps.db, {
|
|
810
810
|
sub: adminSub,
|
|
811
|
-
scopes: ["
|
|
812
|
-
audience: "
|
|
811
|
+
scopes: ["agent:admin"],
|
|
812
|
+
audience: "agent",
|
|
813
813
|
clientId: DELETE_VAULT_CLIENT_ID,
|
|
814
814
|
issuer: deps.issuer,
|
|
815
815
|
ttlSeconds: DELETE_VAULT_PROVISION_TTL_SECONDS,
|
|
@@ -817,7 +817,7 @@ export async function handleDeleteVault(
|
|
|
817
817
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
818
818
|
})
|
|
819
819
|
).token;
|
|
820
|
-
const res = await fetchImpl(`${deps.
|
|
820
|
+
const res = await fetchImpl(`${deps.agentOrigin}/api/channels`, {
|
|
821
821
|
headers: { authorization: `Bearer ${scanToken}`, accept: "application/json" },
|
|
822
822
|
});
|
|
823
823
|
if (res.ok) {
|
|
@@ -830,11 +830,11 @@ export async function handleDeleteVault(
|
|
|
830
830
|
}
|
|
831
831
|
}
|
|
832
832
|
} else {
|
|
833
|
-
warnings.push({ step: "
|
|
833
|
+
warnings.push({ step: "agent_scan", detail: `agent channel list returned ${res.status}` });
|
|
834
834
|
}
|
|
835
835
|
} catch (err) {
|
|
836
836
|
warnings.push({
|
|
837
|
-
step: "
|
|
837
|
+
step: "agent_scan",
|
|
838
838
|
detail: err instanceof Error ? err.message : String(err),
|
|
839
839
|
});
|
|
840
840
|
}
|