@openparachute/hub 0.7.0 → 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__/account-setup.test.ts +276 -6
- 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 +1324 -0
- 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-invites.test.ts +166 -6
- 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__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- 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/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- 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 +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -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 +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- 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-C-XzMVqN.js +0 -61
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* in line with the admin scope-set semantics from hub#214 / #222.
|
|
48
48
|
*/
|
|
49
49
|
import type { Database } from "bun:sqlite";
|
|
50
|
+
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
50
51
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
51
52
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
52
53
|
import { isFirstAdmin } from "./users.ts";
|
|
@@ -72,7 +73,7 @@ export async function handleHostAdminToken(
|
|
|
72
73
|
}
|
|
73
74
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
74
75
|
const session = sid ? findSession(deps.db, sid) : null;
|
|
75
|
-
if (!session) {
|
|
76
|
+
if (!session || !sid) {
|
|
76
77
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
77
78
|
}
|
|
78
79
|
// First-admin gate. A friend account (non-first-admin user created via
|
|
@@ -89,6 +90,18 @@ export async function handleHostAdminToken(
|
|
|
89
90
|
"host-admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
90
91
|
);
|
|
91
92
|
}
|
|
93
|
+
// Admin screen-lock gate (optional, off by default). When a lock PIN is set
|
|
94
|
+
// AND this session isn't within an unlock window, refuse to mint — the SPA
|
|
95
|
+
// shows the lock screen on the 423. No PIN configured → always allowed
|
|
96
|
+
// (today's behavior). This mint is a PURE CHECK — it does NOT slide the idle
|
|
97
|
+
// window; sliding is driven only by genuine user activity (the SPA's debounced
|
|
98
|
+
// `/heartbeat`), so a background re-mint (e.g. the 30s version-badge poll) can't
|
|
99
|
+
// keep an idle tab unlocked. See the `requireUnlocked` docblock in admin-lock.ts.
|
|
100
|
+
// The OAuth issuer (`/oauth/*`) never reaches this endpoint, so it's
|
|
101
|
+
// unaffected by the lock.
|
|
102
|
+
if (!requireUnlocked(deps.db, sid).ok) {
|
|
103
|
+
return lockedResponse();
|
|
104
|
+
}
|
|
92
105
|
const minted = await signAccessToken(deps.db, {
|
|
93
106
|
sub: session.userId,
|
|
94
107
|
scopes: [...HOST_ADMIN_SCOPES],
|
|
@@ -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
|
+
);
|
package/src/admin-login-ui.ts
CHANGED
|
@@ -161,8 +161,18 @@ export interface InviteSetupProps {
|
|
|
161
161
|
/**
|
|
162
162
|
* When the invite pins a vault name the redeemer can't choose one — we show
|
|
163
163
|
* it read-only. When null the redeemer names their own vault (a text field).
|
|
164
|
+
* With `provisionVault: false` a pinned name is a SHARED-VAULT invite: the
|
|
165
|
+
* redeemer is being given `role` access to the operator's existing vault.
|
|
164
166
|
*/
|
|
165
167
|
pinnedVaultName: string | null;
|
|
168
|
+
/**
|
|
169
|
+
* When the invite pre-names the account, the username is shown read-only
|
|
170
|
+
* and ENFORCED server-side (the redeem handler ignores the form field).
|
|
171
|
+
* Null = the redeemer picks their own.
|
|
172
|
+
*/
|
|
173
|
+
pinnedUsername: string | null;
|
|
174
|
+
/** The `user_vaults` role redemption grants ('read' | 'write') — shown on the shared-vault row. */
|
|
175
|
+
role: string;
|
|
166
176
|
/** Whether redemption provisions a vault at all (shows the vault row iff true). */
|
|
167
177
|
provisionVault: boolean;
|
|
168
178
|
username?: string;
|
|
@@ -177,15 +187,55 @@ export interface InviteSetupProps {
|
|
|
177
187
|
* same `/account/setup/<token>` path. Reuses the shared login chrome.
|
|
178
188
|
*/
|
|
179
189
|
export function renderInviteSetup(props: InviteSetupProps): string {
|
|
180
|
-
const {
|
|
181
|
-
|
|
190
|
+
const {
|
|
191
|
+
token,
|
|
192
|
+
csrfToken,
|
|
193
|
+
pinnedVaultName,
|
|
194
|
+
pinnedUsername,
|
|
195
|
+
role,
|
|
196
|
+
provisionVault,
|
|
197
|
+
username,
|
|
198
|
+
vaultName,
|
|
199
|
+
errorMessage,
|
|
200
|
+
} = props;
|
|
182
201
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
183
202
|
const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
|
|
184
203
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
204
|
+
// Username row: pre-named → read-only display (the server ENFORCES the
|
|
205
|
+
// invite's username; the disabled input never submits and the handler
|
|
206
|
+
// ignores the field anyway). Unpinned → the normal pick-a-name field.
|
|
207
|
+
const usernameRow =
|
|
208
|
+
pinnedUsername !== null
|
|
209
|
+
? `
|
|
210
|
+
<label class="field">
|
|
211
|
+
<span class="field-label">Username</span>
|
|
212
|
+
<input type="text" value="${escapeAttr(pinnedUsername)}" readonly disabled />
|
|
213
|
+
<span class="field-hint">Your hub operator chose this username for you.</span>
|
|
214
|
+
</label>`
|
|
215
|
+
: `
|
|
216
|
+
<label class="field">
|
|
217
|
+
<span class="field-label">Username</span>
|
|
218
|
+
<input type="text" name="username" id="username" autocomplete="username" autofocus
|
|
219
|
+
required minlength="2" maxlength="32"
|
|
220
|
+
pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
|
|
221
|
+
spellcheck="false" autocapitalize="off"${usernameAttr} />
|
|
222
|
+
<span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
|
|
223
|
+
</label>`;
|
|
224
|
+
|
|
225
|
+
// Vault row. Provisioning invites show the new vault's name (pinned →
|
|
226
|
+
// read-only; unpinned → a text field the redeemer fills). A shared-vault
|
|
227
|
+
// invite (no provisioning + a pinned name) shows what the redeemer is
|
|
228
|
+
// being given access to, including the role.
|
|
188
229
|
let vaultRow = "";
|
|
230
|
+
if (!provisionVault && pinnedVaultName !== null) {
|
|
231
|
+
const roleLabel = role === "read" ? "read-only" : "read & write";
|
|
232
|
+
vaultRow = `
|
|
233
|
+
<label class="field">
|
|
234
|
+
<span class="field-label">Shared vault</span>
|
|
235
|
+
<input type="text" value="${escapeAttr(pinnedVaultName)}" readonly disabled />
|
|
236
|
+
<span class="field-hint">You're being given ${roleLabel} access to this existing vault.</span>
|
|
237
|
+
</label>`;
|
|
238
|
+
}
|
|
189
239
|
if (provisionVault) {
|
|
190
240
|
if (pinnedVaultName !== null) {
|
|
191
241
|
vaultRow = `
|
|
@@ -240,21 +290,20 @@ export function renderInviteSetup(props: InviteSetupProps): string {
|
|
|
240
290
|
<div class="card-header">
|
|
241
291
|
${header()}
|
|
242
292
|
<h1>Claim your invite</h1>
|
|
243
|
-
<p class="subtitle"
|
|
244
|
-
|
|
293
|
+
<p class="subtitle">${
|
|
294
|
+
pinnedUsername !== null ? "Pick a password" : "Pick a username and password"
|
|
295
|
+
} to create your Parachute account${
|
|
296
|
+
provisionVault
|
|
297
|
+
? " and your own vault"
|
|
298
|
+
: pinnedVaultName !== null
|
|
299
|
+
? " with access to a shared vault"
|
|
300
|
+
: ""
|
|
245
301
|
}.</p>
|
|
246
302
|
</div>
|
|
247
303
|
${error}
|
|
248
304
|
<form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
|
|
249
305
|
${renderCsrfHiddenInput(csrfToken)}
|
|
250
|
-
|
|
251
|
-
<span class="field-label">Username</span>
|
|
252
|
-
<input type="text" name="username" id="username" autocomplete="username" autofocus
|
|
253
|
-
required minlength="2" maxlength="32"
|
|
254
|
-
pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
|
|
255
|
-
spellcheck="false" autocapitalize="off"${usernameAttr} />
|
|
256
|
-
<span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
|
|
257
|
-
</label>
|
|
306
|
+
${usernameRow}
|
|
258
307
|
<label class="field">
|
|
259
308
|
<span class="field-label">Password</span>
|
|
260
309
|
<input type="password" name="password" autocomplete="new-password"
|
|
@@ -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,10 +537,16 @@ 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
|
+
/**
|
|
545
|
+
* Resolve a module's loopback origin by short name (H4 — best-effort
|
|
546
|
+
* credential-removal notification during connection teardown). Optional:
|
|
547
|
+
* absent → the notification step records a warning, revocation still runs.
|
|
548
|
+
*/
|
|
549
|
+
resolveModuleOrigin?: (short: string) => string | null;
|
|
544
550
|
/** Test seam: run `parachute-vault remove` — same Runner seam as create. */
|
|
545
551
|
runCommand?: CreateVaultDeps["runCommand"];
|
|
546
552
|
/**
|
|
@@ -645,7 +651,7 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
645
651
|
* the name);
|
|
646
652
|
* 5. connections whose source/provisioned vault is the deleted vault
|
|
647
653
|
* (via `teardownConnection`, which post-B0 also revokes the registered
|
|
648
|
-
* long-lived mints) + a report-only scan of
|
|
654
|
+
* long-lived mints) + a report-only scan of the agent's `/api/channels`
|
|
649
655
|
* for legacy vault-backed entries (`orphaned_channels`);
|
|
650
656
|
* 6. mechanics: shell to `parachute-vault remove <name> --yes` (the module
|
|
651
657
|
* CLI stays the single source of truth for vault destruction,
|
|
@@ -755,7 +761,10 @@ export async function handleDeleteVault(
|
|
|
755
761
|
hubOrigin: deps.issuer,
|
|
756
762
|
modules: [], // teardown never consults the catalog
|
|
757
763
|
resolveVaultOrigin: deps.resolveVaultOrigin,
|
|
758
|
-
|
|
764
|
+
...(deps.resolveModuleOrigin !== undefined
|
|
765
|
+
? { resolveModuleOrigin: deps.resolveModuleOrigin }
|
|
766
|
+
: {}),
|
|
767
|
+
agentOrigin: deps.agentOrigin,
|
|
759
768
|
storePath: deps.connectionsStorePath,
|
|
760
769
|
...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
|
|
761
770
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
@@ -788,9 +797,9 @@ export async function handleDeleteVault(
|
|
|
788
797
|
|
|
789
798
|
// Legacy channel scan — vault-backed channel entries still referencing the
|
|
790
799
|
// vault after connection teardown (pre-Connections wiring). REPORT ONLY:
|
|
791
|
-
// channel
|
|
792
|
-
// them from
|
|
793
|
-
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) {
|
|
794
803
|
try {
|
|
795
804
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
796
805
|
// Short-lived (60s) — stays below the registered-mint threshold by
|
|
@@ -799,8 +808,8 @@ export async function handleDeleteVault(
|
|
|
799
808
|
const scanToken = (
|
|
800
809
|
await signAccessToken(deps.db, {
|
|
801
810
|
sub: adminSub,
|
|
802
|
-
scopes: ["
|
|
803
|
-
audience: "
|
|
811
|
+
scopes: ["agent:admin"],
|
|
812
|
+
audience: "agent",
|
|
804
813
|
clientId: DELETE_VAULT_CLIENT_ID,
|
|
805
814
|
issuer: deps.issuer,
|
|
806
815
|
ttlSeconds: DELETE_VAULT_PROVISION_TTL_SECONDS,
|
|
@@ -808,7 +817,7 @@ export async function handleDeleteVault(
|
|
|
808
817
|
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
809
818
|
})
|
|
810
819
|
).token;
|
|
811
|
-
const res = await fetchImpl(`${deps.
|
|
820
|
+
const res = await fetchImpl(`${deps.agentOrigin}/api/channels`, {
|
|
812
821
|
headers: { authorization: `Bearer ${scanToken}`, accept: "application/json" },
|
|
813
822
|
});
|
|
814
823
|
if (res.ok) {
|
|
@@ -821,11 +830,11 @@ export async function handleDeleteVault(
|
|
|
821
830
|
}
|
|
822
831
|
}
|
|
823
832
|
} else {
|
|
824
|
-
warnings.push({ step: "
|
|
833
|
+
warnings.push({ step: "agent_scan", detail: `agent channel list returned ${res.status}` });
|
|
825
834
|
}
|
|
826
835
|
} catch (err) {
|
|
827
836
|
warnings.push({
|
|
828
|
-
step: "
|
|
837
|
+
step: "agent_scan",
|
|
829
838
|
detail: err instanceof Error ? err.message : String(err),
|
|
830
839
|
});
|
|
831
840
|
}
|