@openparachute/hub 0.5.13 → 0.5.14-rc.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/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-rendered HTML for `/account/` — the friend-facing user home.
|
|
3
|
+
*
|
|
4
|
+
* Multi-user Phase 1 follow-up. Companion to the first-admin gate on
|
|
5
|
+
* `/admin/host-admin-token` (see `admin-host-admin-token.ts`): without
|
|
6
|
+
* this landing surface, a non-admin friend signing in would either
|
|
7
|
+
* (a) hit a 403 wall when the SPA tries to mint a host-admin bearer,
|
|
8
|
+
* or (b) — pre-gate — silently escalate to full admin. The gate plus
|
|
9
|
+
* this page give the friend a coherent home: their assigned vault,
|
|
10
|
+
* password rotation link, sign-out.
|
|
11
|
+
*
|
|
12
|
+
* Pure renderer — no DB, no Bun.serve, no fs. The `/account/` route
|
|
13
|
+
* handler in `hub-server.ts` resolves the user + their vault + the
|
|
14
|
+
* is-first-admin flag, then calls in here. Same posture as
|
|
15
|
+
* `account-change-password-ui.ts` and `oauth-ui.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Visual chrome: cloned from `account-change-password-ui.ts` so the
|
|
18
|
+
* `/account/*` surface family stays cohesive. If/when an
|
|
19
|
+
* `auth-ui-chrome.ts` lands, both should consolidate.
|
|
20
|
+
*/
|
|
21
|
+
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
22
|
+
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
23
|
+
import { escapeHtml } from "./oauth-ui.ts";
|
|
24
|
+
|
|
25
|
+
// --- shared chrome (mirrors account-change-password-ui.ts) ----------------
|
|
26
|
+
|
|
27
|
+
const PALETTE = {
|
|
28
|
+
bg: "#faf8f4",
|
|
29
|
+
bgSoft: "#f3f0ea",
|
|
30
|
+
fg: "#2c2a26",
|
|
31
|
+
fgMuted: "#6b6860",
|
|
32
|
+
fgDim: "#9a9690",
|
|
33
|
+
accent: "#4a7c59",
|
|
34
|
+
accentHover: "#3d6849",
|
|
35
|
+
accentSoft: "rgba(74, 124, 89, 0.08)",
|
|
36
|
+
border: "#e4e0d8",
|
|
37
|
+
borderLight: "#ece9e2",
|
|
38
|
+
cardBg: "#ffffff",
|
|
39
|
+
danger: "#a3392b",
|
|
40
|
+
dangerSoft: "rgba(163, 57, 43, 0.08)",
|
|
41
|
+
success: "#3d6849",
|
|
42
|
+
successSoft: "rgba(61, 104, 73, 0.08)",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
const FONT_SERIF = `Georgia, "Times New Roman", serif`;
|
|
46
|
+
const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
|
|
47
|
+
const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
|
|
48
|
+
|
|
49
|
+
function baseDocument(title: string, body: string): string {
|
|
50
|
+
return `<!doctype html>
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="utf-8" />
|
|
54
|
+
<title>${escapeHtml(title)}</title>
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
56
|
+
<meta name="referrer" content="no-referrer" />
|
|
57
|
+
<style>${STYLES}</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<main>
|
|
61
|
+
${body}
|
|
62
|
+
</main>
|
|
63
|
+
</body>
|
|
64
|
+
</html>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function header(): string {
|
|
68
|
+
return `
|
|
69
|
+
<div class="brand">
|
|
70
|
+
<span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "account-home")}</span>
|
|
71
|
+
<span class="brand-name">${WORDMARK_TEXT}</span>
|
|
72
|
+
<span class="brand-tag">account</span>
|
|
73
|
+
</div>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- /account/ ------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export interface RenderAccountHomeOpts {
|
|
79
|
+
username: string;
|
|
80
|
+
/**
|
|
81
|
+
* Vault instance names this user has access to (multi-user Phase 2
|
|
82
|
+
* PR 2). Empty `[]` for admin posture (combined with `isFirstAdmin:
|
|
83
|
+
* true` this is the hub administrator's account, whose vault access
|
|
84
|
+
* is unrestricted). One or more entries for non-admin users — the
|
|
85
|
+
* page renders one Notes-CTA tile per vault.
|
|
86
|
+
*/
|
|
87
|
+
assignedVaults: string[];
|
|
88
|
+
/** Force-change-password completion flag. Currently informational only. */
|
|
89
|
+
passwordChanged: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Hub origin (no trailing slash) — the canonical URL operators connect
|
|
92
|
+
* their MCP / Surface clients to. Used both in the Notes "Open" CTA
|
|
93
|
+
* (encoded as `?url=...` on `notes.parachute.computer/add`) and as
|
|
94
|
+
* inline-code text for the "custom client" disclosure.
|
|
95
|
+
*/
|
|
96
|
+
hubOrigin: string;
|
|
97
|
+
/**
|
|
98
|
+
* Whether the signed-in user is the hub administrator (the
|
|
99
|
+
* earliest-created users row). Drives the `assignedVault: null`
|
|
100
|
+
* branch — admins see an "open admin" affordance; non-admins (a
|
|
101
|
+
* Phase 1 shape that shouldn't normally occur — admins land with
|
|
102
|
+
* `assignedVault: null`, friends always have one set) see a
|
|
103
|
+
* defensive "ask the operator" message.
|
|
104
|
+
*/
|
|
105
|
+
isFirstAdmin: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* CSRF token for the sign-out form. Same pattern as
|
|
108
|
+
* `account-change-password-ui.ts` — POSTs to `/logout` are CSRF-gated
|
|
109
|
+
* to keep a cross-origin form from logging the user out.
|
|
110
|
+
*/
|
|
111
|
+
csrfToken: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
115
|
+
const { username, assignedVaults, hubOrigin, isFirstAdmin, csrfToken } = opts;
|
|
116
|
+
const safeUsername = escapeHtml(username);
|
|
117
|
+
// Origin is already canonicalized by the handler (trailing slash stripped),
|
|
118
|
+
// but defensively re-trim so a stray slash here doesn't break the CTA href.
|
|
119
|
+
const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
|
|
120
|
+
|
|
121
|
+
const vaultCard = renderVaultCard({
|
|
122
|
+
assignedVaults,
|
|
123
|
+
trimmedOrigin,
|
|
124
|
+
isFirstAdmin,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const accountCard = renderAccountCard({
|
|
128
|
+
username: safeUsername,
|
|
129
|
+
csrfToken,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const body = `
|
|
133
|
+
<div class="card">
|
|
134
|
+
<div class="card-header">
|
|
135
|
+
${header()}
|
|
136
|
+
<h1>Welcome, ${safeUsername}</h1>
|
|
137
|
+
<p class="subtitle">Your Parachute account home.</p>
|
|
138
|
+
</div>
|
|
139
|
+
${vaultCard}
|
|
140
|
+
${accountCard}
|
|
141
|
+
</div>`;
|
|
142
|
+
return baseDocument(`${username} — Parachute`, body);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface VaultCardOpts {
|
|
146
|
+
assignedVaults: string[];
|
|
147
|
+
trimmedOrigin: string;
|
|
148
|
+
isFirstAdmin: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderVaultCard(opts: VaultCardOpts): string {
|
|
152
|
+
const { assignedVaults, trimmedOrigin, isFirstAdmin } = opts;
|
|
153
|
+
|
|
154
|
+
if (assignedVaults.length > 0) {
|
|
155
|
+
// One vault tile per assignment (multi-user Phase 2 PR 2). Each
|
|
156
|
+
// tile carries its own Notes "Open" CTA and the hub-origin code
|
|
157
|
+
// block. The disclosure ("Use a custom client") lives at the
|
|
158
|
+
// section level, not per-tile, because the hub origin is the same
|
|
159
|
+
// regardless of which vault the user picks.
|
|
160
|
+
const hubOriginDisplay = escapeHtml(trimmedOrigin);
|
|
161
|
+
const heading = assignedVaults.length === 1 ? "<h2>Your vault</h2>" : "<h2>Your vaults</h2>";
|
|
162
|
+
const tiles = assignedVaults
|
|
163
|
+
.map((vaultName) => {
|
|
164
|
+
const safeVault = escapeHtml(vaultName);
|
|
165
|
+
const vaultUrlForAdd = encodeURIComponent(`${trimmedOrigin}/vault/${vaultName}`);
|
|
166
|
+
return `
|
|
167
|
+
<div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
|
|
168
|
+
<p class="vault-name"><strong>${safeVault}</strong></p>
|
|
169
|
+
<p>
|
|
170
|
+
<a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
|
|
171
|
+
target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
|
|
172
|
+
</p>
|
|
173
|
+
</div>`;
|
|
174
|
+
})
|
|
175
|
+
.join("");
|
|
176
|
+
return `
|
|
177
|
+
<section class="section" data-testid="vault-card">
|
|
178
|
+
${heading}
|
|
179
|
+
<p>Open Notes — the canonical browser UI for your vault${
|
|
180
|
+
assignedVaults.length === 1 ? "" : "s"
|
|
181
|
+
}. It connects to your hub
|
|
182
|
+
over HTTPS and remembers your URL after the first OAuth.</p>
|
|
183
|
+
<div class="vault-tiles">${tiles}
|
|
184
|
+
</div>
|
|
185
|
+
<details class="custom-client">
|
|
186
|
+
<summary>Use a custom client</summary>
|
|
187
|
+
<p>To connect a custom Surface or MCP client running on your own machine,
|
|
188
|
+
point it at the hub origin below and run through OAuth — the hub will
|
|
189
|
+
ask you to consent.</p>
|
|
190
|
+
<p class="hub-origin-line">Hub origin: <code>${hubOriginDisplay}</code></p>
|
|
191
|
+
</details>
|
|
192
|
+
</section>`;
|
|
193
|
+
}
|
|
194
|
+
if (isFirstAdmin) {
|
|
195
|
+
return `
|
|
196
|
+
<section class="section" data-testid="admin-card">
|
|
197
|
+
<h2>Your vault</h2>
|
|
198
|
+
<p>You're the hub administrator. Visit
|
|
199
|
+
<a href="/admin/">the admin surface</a> to manage vaults, users, and clients.</p>
|
|
200
|
+
</section>`;
|
|
201
|
+
}
|
|
202
|
+
// Defensive third branch — non-admin with no assigned vault. The
|
|
203
|
+
// /api/users path doesn't require a vault on create (admins can leave
|
|
204
|
+
// a new friend's vault list empty and fill it in later), so this
|
|
205
|
+
// state is reachable through normal flows — surface a clear "ask
|
|
206
|
+
// your admin" message.
|
|
207
|
+
return `
|
|
208
|
+
<section class="section" data-testid="no-vault-card">
|
|
209
|
+
<h2>Your vault</h2>
|
|
210
|
+
<p>Your account isn't assigned to a vault yet. Ask the hub operator
|
|
211
|
+
to assign one.</p>
|
|
212
|
+
</section>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface AccountCardOpts {
|
|
216
|
+
username: string;
|
|
217
|
+
csrfToken: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderAccountCard(opts: AccountCardOpts): string {
|
|
221
|
+
const { username, csrfToken } = opts;
|
|
222
|
+
return `
|
|
223
|
+
<section class="section" data-testid="account-card">
|
|
224
|
+
<h2>Account</h2>
|
|
225
|
+
<dl class="kv">
|
|
226
|
+
<dt>Username</dt>
|
|
227
|
+
<dd><code>${username}</code></dd>
|
|
228
|
+
</dl>
|
|
229
|
+
<p>
|
|
230
|
+
<a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
|
|
231
|
+
</p>
|
|
232
|
+
<form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
|
|
233
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
234
|
+
<button type="submit" class="btn btn-secondary">Sign out</button>
|
|
235
|
+
</form>
|
|
236
|
+
</section>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- styles ---------------------------------------------------------------
|
|
240
|
+
//
|
|
241
|
+
// Same brand palette + font stack as account-change-password-ui.ts so the
|
|
242
|
+
// `/account/*` family is visually cohesive. Extra rules (.section, .kv,
|
|
243
|
+
// .vault-name, .custom-client, .hub-origin-line) describe the new card +
|
|
244
|
+
// disclosure shapes this page introduces.
|
|
245
|
+
|
|
246
|
+
const STYLES = `
|
|
247
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
248
|
+
html, body { margin: 0; padding: 0; }
|
|
249
|
+
body {
|
|
250
|
+
font-family: ${FONT_SANS};
|
|
251
|
+
background: ${PALETTE.bg};
|
|
252
|
+
color: ${PALETTE.fg};
|
|
253
|
+
line-height: 1.55;
|
|
254
|
+
min-height: 100vh;
|
|
255
|
+
-webkit-font-smoothing: antialiased;
|
|
256
|
+
-moz-osx-font-smoothing: grayscale;
|
|
257
|
+
}
|
|
258
|
+
main {
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: flex-start;
|
|
261
|
+
justify-content: center;
|
|
262
|
+
min-height: 100vh;
|
|
263
|
+
padding: 2rem 1.5rem;
|
|
264
|
+
}
|
|
265
|
+
.card {
|
|
266
|
+
width: 100%;
|
|
267
|
+
max-width: 34rem;
|
|
268
|
+
background: ${PALETTE.cardBg};
|
|
269
|
+
border: 1px solid ${PALETTE.border};
|
|
270
|
+
border-radius: 12px;
|
|
271
|
+
padding: 2rem 1.75rem;
|
|
272
|
+
box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
|
|
273
|
+
}
|
|
274
|
+
.card-header { margin-bottom: 1.5rem; }
|
|
275
|
+
.brand {
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 0.5rem;
|
|
279
|
+
color: ${PALETTE.accent};
|
|
280
|
+
font-weight: 500;
|
|
281
|
+
font-size: 0.95rem;
|
|
282
|
+
margin-bottom: 1.25rem;
|
|
283
|
+
}
|
|
284
|
+
.brand-mark { display: inline-flex; line-height: 0; }
|
|
285
|
+
.brand-mark svg { width: 20px; height: 20px; }
|
|
286
|
+
.brand-name { letter-spacing: 0.01em; }
|
|
287
|
+
.brand-tag {
|
|
288
|
+
text-transform: uppercase;
|
|
289
|
+
letter-spacing: 0.06em;
|
|
290
|
+
font-size: 0.7rem;
|
|
291
|
+
color: ${PALETTE.fgMuted};
|
|
292
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
293
|
+
padding: 0.05rem 0.4rem;
|
|
294
|
+
border-radius: 999px;
|
|
295
|
+
}
|
|
296
|
+
h1 {
|
|
297
|
+
font-family: ${FONT_SERIF};
|
|
298
|
+
font-weight: 400;
|
|
299
|
+
font-size: 1.6rem;
|
|
300
|
+
line-height: 1.2;
|
|
301
|
+
margin: 0 0 0.4rem;
|
|
302
|
+
color: ${PALETTE.fg};
|
|
303
|
+
}
|
|
304
|
+
h2 {
|
|
305
|
+
font-family: ${FONT_SERIF};
|
|
306
|
+
font-weight: 400;
|
|
307
|
+
font-size: 1.15rem;
|
|
308
|
+
line-height: 1.25;
|
|
309
|
+
margin: 0 0 0.6rem;
|
|
310
|
+
color: ${PALETTE.fg};
|
|
311
|
+
}
|
|
312
|
+
.subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
|
|
313
|
+
|
|
314
|
+
.section {
|
|
315
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
316
|
+
padding-top: 1.25rem;
|
|
317
|
+
margin-top: 1.25rem;
|
|
318
|
+
}
|
|
319
|
+
.section p { margin: 0.4rem 0; }
|
|
320
|
+
.vault-name {
|
|
321
|
+
font-family: ${FONT_MONO};
|
|
322
|
+
font-size: 1rem;
|
|
323
|
+
margin: 0 0 0.6rem;
|
|
324
|
+
}
|
|
325
|
+
.vault-name strong { color: ${PALETTE.fg}; font-weight: 600; }
|
|
326
|
+
.vault-tiles {
|
|
327
|
+
display: flex;
|
|
328
|
+
flex-direction: column;
|
|
329
|
+
gap: 0.75rem;
|
|
330
|
+
margin: 0.75rem 0 0.4rem;
|
|
331
|
+
}
|
|
332
|
+
.vault-tile {
|
|
333
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
334
|
+
border-radius: 8px;
|
|
335
|
+
padding: 0.75rem 1rem;
|
|
336
|
+
background: ${PALETTE.bgSoft};
|
|
337
|
+
}
|
|
338
|
+
.vault-tile p { margin: 0.2rem 0; }
|
|
339
|
+
.vault-tile p:last-child { margin-top: 0.5rem; }
|
|
340
|
+
|
|
341
|
+
.custom-client { margin-top: 0.8rem; }
|
|
342
|
+
.custom-client summary {
|
|
343
|
+
cursor: pointer;
|
|
344
|
+
font-size: 0.85rem;
|
|
345
|
+
color: ${PALETTE.fgMuted};
|
|
346
|
+
font-family: ${FONT_MONO};
|
|
347
|
+
padding: 0.25rem 0;
|
|
348
|
+
user-select: none;
|
|
349
|
+
}
|
|
350
|
+
.custom-client[open] summary { color: ${PALETTE.fg}; }
|
|
351
|
+
.custom-client p {
|
|
352
|
+
font-size: 0.9rem;
|
|
353
|
+
color: ${PALETTE.fgMuted};
|
|
354
|
+
margin: 0.4rem 0;
|
|
355
|
+
}
|
|
356
|
+
.hub-origin-line { font-family: ${FONT_MONO}; }
|
|
357
|
+
code {
|
|
358
|
+
font-family: ${FONT_MONO};
|
|
359
|
+
background: ${PALETTE.bgSoft};
|
|
360
|
+
padding: 0.1rem 0.4rem;
|
|
361
|
+
border-radius: 4px;
|
|
362
|
+
font-size: 0.88em;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.kv { margin: 0 0 0.6rem; padding: 0; }
|
|
366
|
+
.kv dt {
|
|
367
|
+
font-size: 0.78rem;
|
|
368
|
+
text-transform: uppercase;
|
|
369
|
+
letter-spacing: 0.06em;
|
|
370
|
+
color: ${PALETTE.fgMuted};
|
|
371
|
+
font-family: ${FONT_MONO};
|
|
372
|
+
margin-top: 0.4rem;
|
|
373
|
+
}
|
|
374
|
+
.kv dd { margin: 0.15rem 0 0.4rem; }
|
|
375
|
+
|
|
376
|
+
.btn {
|
|
377
|
+
font: inherit;
|
|
378
|
+
font-weight: 500;
|
|
379
|
+
padding: 0.55rem 1rem;
|
|
380
|
+
border-radius: 6px;
|
|
381
|
+
border: 1px solid transparent;
|
|
382
|
+
cursor: pointer;
|
|
383
|
+
text-decoration: none;
|
|
384
|
+
display: inline-block;
|
|
385
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
386
|
+
}
|
|
387
|
+
.btn-primary {
|
|
388
|
+
background: ${PALETTE.accent};
|
|
389
|
+
color: ${PALETTE.cardBg};
|
|
390
|
+
}
|
|
391
|
+
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
392
|
+
.btn-secondary {
|
|
393
|
+
background: transparent;
|
|
394
|
+
color: ${PALETTE.fg};
|
|
395
|
+
border-color: ${PALETTE.border};
|
|
396
|
+
}
|
|
397
|
+
.btn-secondary:hover {
|
|
398
|
+
background: ${PALETTE.bgSoft};
|
|
399
|
+
border-color: ${PALETTE.accent};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.signout-form { margin: 0.8rem 0 0; }
|
|
403
|
+
|
|
404
|
+
.account-action {
|
|
405
|
+
color: ${PALETTE.accent};
|
|
406
|
+
text-decoration: none;
|
|
407
|
+
font-weight: 500;
|
|
408
|
+
font-size: 0.95rem;
|
|
409
|
+
}
|
|
410
|
+
.account-action:hover { color: ${PALETTE.accentHover}; text-decoration: underline; }
|
|
411
|
+
|
|
412
|
+
a { color: ${PALETTE.accent}; }
|
|
413
|
+
a:hover { color: ${PALETTE.accentHover}; }
|
|
414
|
+
|
|
415
|
+
@media (max-width: 480px) {
|
|
416
|
+
main { padding: 1rem 0.75rem; }
|
|
417
|
+
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
|
418
|
+
h1 { font-size: 1.4rem; }
|
|
419
|
+
h2 { font-size: 1.05rem; }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
@media (prefers-color-scheme: dark) {
|
|
423
|
+
body { background: #1a1815; color: #e8e4dc; }
|
|
424
|
+
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
425
|
+
h1, h2 { color: #f0ece4; }
|
|
426
|
+
.subtitle, .kv dt, .custom-client summary { color: #a8a29a; }
|
|
427
|
+
.vault-name strong { color: #f0ece4; }
|
|
428
|
+
code { background: #1f1c18; color: #e8e4dc; }
|
|
429
|
+
.section { border-top-color: #3a362f; }
|
|
430
|
+
.brand-tag { border-color: #3a362f; color: #a8a29a; }
|
|
431
|
+
.btn-secondary { color: #e8e4dc; border-color: #3a362f; }
|
|
432
|
+
.btn-secondary:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
|
|
433
|
+
}
|
|
434
|
+
`;
|
package/src/admin-handlers.ts
CHANGED
|
@@ -23,7 +23,13 @@ import {
|
|
|
23
23
|
deleteSession,
|
|
24
24
|
parseSessionCookie,
|
|
25
25
|
} from "./sessions.ts";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
PASSWORD_MAX_LEN,
|
|
28
|
+
type User,
|
|
29
|
+
getUserByUsername,
|
|
30
|
+
isFirstAdmin,
|
|
31
|
+
verifyPassword,
|
|
32
|
+
} from "./users.ts";
|
|
27
33
|
|
|
28
34
|
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
29
35
|
return new Response(body, {
|
|
@@ -41,8 +47,12 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
|
|
|
41
47
|
* `/admin/vaults` as its home (vault list, the default tab). Anywhere else
|
|
42
48
|
* would either bounce the operator out of the SPA shell or land on a
|
|
43
49
|
* legacy server-rendered page — `/admin/vaults` is the canonical entry.
|
|
50
|
+
*
|
|
51
|
+
* Exported because `api-account.ts` consumes the same constant for its
|
|
52
|
+
* change-password landing. Single source of truth so a future "default
|
|
53
|
+
* landing changed to /admin/dashboard" PR doesn't drift across two files.
|
|
44
54
|
*/
|
|
45
|
-
const POST_LOGIN_DEFAULT = "/admin/vaults";
|
|
55
|
+
export const POST_LOGIN_DEFAULT = "/admin/vaults";
|
|
46
56
|
|
|
47
57
|
/**
|
|
48
58
|
* Force-change-password landing. Multi-user Phase 1 PR 3: when a user
|
|
@@ -75,22 +85,44 @@ function safeNext(raw: string | null): string {
|
|
|
75
85
|
* boundary — once changed, no per-request scope check is needed and
|
|
76
86
|
* no token claim carries the bit forward.
|
|
77
87
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
88
|
+
* Three precedence rules, in order:
|
|
89
|
+
*
|
|
90
|
+
* 1. `password_changed === false` → `/account/change-password`
|
|
91
|
+
* (preserves `next` as a query param so the change-password POST
|
|
92
|
+
* finishes the trip).
|
|
93
|
+
* 2. Non-admin user (friend) whose `next` targets `/admin/*` → rewrite
|
|
94
|
+
* to `/account/`. Friends have no business on admin SPA URLs —
|
|
95
|
+
* `/admin/host-admin-token` would 403 (first-admin gate) and the
|
|
96
|
+
* SPA would bounce them to `/account/` anyway. Doing the rewrite
|
|
97
|
+
* at the login boundary avoids the bouncing-around UX. Other `next`
|
|
98
|
+
* paths (`/`, `/oauth/authorize/...`, `/vault/...`) are legitimate
|
|
99
|
+
* destinations for non-admin users and pass through unchanged.
|
|
100
|
+
* 3. Otherwise return `next` (the today's-default behavior).
|
|
82
101
|
*
|
|
83
|
-
*
|
|
102
|
+
* `db` is required for the non-admin check (it consults `getFirstAdminId`).
|
|
103
|
+
* Wizard-only test fixtures that pre-existed the `db` plumbing pass the
|
|
104
|
+
* harness DB through; the parameter is non-optional to make the
|
|
105
|
+
* "did you remember the gate?" review check explicit.
|
|
84
106
|
*/
|
|
85
|
-
export function loginRedirectTarget(user: User, next: string): string {
|
|
86
|
-
if (user.passwordChanged)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
export function loginRedirectTarget(db: Database, user: User, next: string): string {
|
|
108
|
+
if (!user.passwordChanged) {
|
|
109
|
+
// Preserve the operator's intended destination; the change-password
|
|
110
|
+
// POST will read this and 302 there after the change lands.
|
|
111
|
+
// Only encode `next` if it isn't already the post-change default —
|
|
112
|
+
// keeps the URL clean for the common case (no `next` param specified
|
|
113
|
+
// at login time → no `?next=` on the change-password URL).
|
|
114
|
+
if (next === POST_LOGIN_DEFAULT) return FORCE_CHANGE_PASSWORD_PATH;
|
|
115
|
+
return `${FORCE_CHANGE_PASSWORD_PATH}?next=${encodeURIComponent(next)}`;
|
|
116
|
+
}
|
|
117
|
+
// Non-admin friend aiming at admin SPA: rewrite to /account/. We check
|
|
118
|
+
// both the literal `/admin` and the `/admin/...` prefix; safeNext has
|
|
119
|
+
// already normalized to a leading-`/` same-origin path, so a plain
|
|
120
|
+
// string prefix check is sufficient (no `//admin.evil.com/` shape can
|
|
121
|
+
// reach here).
|
|
122
|
+
if (!isFirstAdmin(db, user.id) && (next === "/admin" || next.startsWith("/admin/"))) {
|
|
123
|
+
return "/account/";
|
|
124
|
+
}
|
|
125
|
+
return next;
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
// --- /login ---------------------------------------------------------------
|
|
@@ -193,7 +225,7 @@ export async function handleAdminLoginPost(
|
|
|
193
225
|
// to change the admin-typed default before continuing. Their original
|
|
194
226
|
// `next` rides along on the change-password URL so the post-change
|
|
195
227
|
// POST can land them at their intended destination.
|
|
196
|
-
const target = loginRedirectTarget(user, next);
|
|
228
|
+
const target = loginRedirectTarget(db, user, next);
|
|
197
229
|
return redirect(target, { "set-cookie": cookie });
|
|
198
230
|
}
|
|
199
231
|
|
|
@@ -25,6 +25,16 @@
|
|
|
25
25
|
* - this endpoint requires a valid `parachute_hub_session` cookie, which
|
|
26
26
|
* was set by `/login` after a password check.
|
|
27
27
|
*
|
|
28
|
+
* **First-admin gate (multi-user Phase 1 follow-up).** A valid session is
|
|
29
|
+
* necessary but NOT sufficient. The session must belong to the hub admin
|
|
30
|
+
* (the earliest-created user row, per `getFirstAdminId` in users.ts).
|
|
31
|
+
* Without this gate, any signed-in friend account created via PR 2's
|
|
32
|
+
* `/api/users` could hit this endpoint and walk away with a JWT carrying
|
|
33
|
+
* `parachute:host:admin` + `parachute:host:auth` — a full-admin privesc,
|
|
34
|
+
* since both scopes are the SPA's gate-bypass into vault provisioning,
|
|
35
|
+
* grant management, and the token registry. The SPA-side mirror is in
|
|
36
|
+
* `web/ui/src/lib/auth.ts`: 403 → redirect to `/account/`.
|
|
37
|
+
*
|
|
28
38
|
* Tokens minted here are deliberately NOT persisted in the `tokens` table
|
|
29
39
|
* (no refresh, no revocation tracking). They expire on their own; the SPA
|
|
30
40
|
* re-fetches when the JWT is about to lapse.
|
|
@@ -39,6 +49,7 @@
|
|
|
39
49
|
import type { Database } from "bun:sqlite";
|
|
40
50
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
41
51
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
52
|
+
import { isFirstAdmin } from "./users.ts";
|
|
42
53
|
|
|
43
54
|
/** Short TTL — page-snapshot threats can't carry the token forever. */
|
|
44
55
|
export const HOST_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
|
|
@@ -64,6 +75,20 @@ export async function handleHostAdminToken(
|
|
|
64
75
|
if (!session) {
|
|
65
76
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
66
77
|
}
|
|
78
|
+
// First-admin gate. A friend account (non-first-admin user created via
|
|
79
|
+
// `/api/users`) holds a valid session but must not be able to mint
|
|
80
|
+
// host-admin scopes. Without this check, any signed-in friend hitting
|
|
81
|
+
// `GET /admin/host-admin-token` would walk away with a JWT carrying
|
|
82
|
+
// `parachute:host:admin` + `parachute:host:auth` — full admin access.
|
|
83
|
+
// The 403 here is mirrored on the SPA side in `web/ui/src/lib/auth.ts`:
|
|
84
|
+
// 403 → redirect to `/account/` (the friend's home).
|
|
85
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
86
|
+
return jsonError(
|
|
87
|
+
403,
|
|
88
|
+
"not_admin",
|
|
89
|
+
"host-admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
67
92
|
const minted = await signAccessToken(deps.db, {
|
|
68
93
|
sub: session.userId,
|
|
69
94
|
scopes: [...HOST_ADMIN_SCOPES],
|
|
@@ -18,10 +18,16 @@
|
|
|
18
18
|
* masking a typo as a real (but unusable) credential. Resolved via the
|
|
19
19
|
* already-built well-known doc — same source of truth the SPA's vault list
|
|
20
20
|
* reads.
|
|
21
|
+
*
|
|
22
|
+
* Multi-user Phase 1 gate: the session must belong to the first admin (the
|
|
23
|
+
* single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`).
|
|
24
|
+
* Friends pinned to a vault use the OAuth flow to get vault:<name>:read/write
|
|
25
|
+
* for their assigned vault; they don't get vault admin via this endpoint.
|
|
21
26
|
*/
|
|
22
27
|
import type { Database } from "bun:sqlite";
|
|
23
28
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
24
29
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
30
|
+
import { isFirstAdmin } from "./users.ts";
|
|
25
31
|
|
|
26
32
|
/** Short TTL — matches host-admin-token. SPA re-fetches on near-expiry. */
|
|
27
33
|
export const VAULT_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
|
|
@@ -57,6 +63,17 @@ export async function handleVaultAdminToken(
|
|
|
57
63
|
if (!session) {
|
|
58
64
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
59
65
|
}
|
|
66
|
+
// Multi-user Phase 1 privesc gate (mirrors host-admin-token). vault:<name>:admin
|
|
67
|
+
// is the per-vault operator scope used by the vault admin SPA — friends pinned
|
|
68
|
+
// to a vault get vault:<name>:read/write via OAuth, never admin. Without this
|
|
69
|
+
// gate, any signed-in friend can mint a vault admin token for any vault.
|
|
70
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
71
|
+
return jsonError(
|
|
72
|
+
403,
|
|
73
|
+
"not_admin",
|
|
74
|
+
"vault admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
60
77
|
const scope = `vault:${vaultName}:admin`;
|
|
61
78
|
// Per-vault audience: vault validates the JWT's `aud` claim against
|
|
62
79
|
// `vault.<name>` derived from its own URL-bound config (vault src/auth.ts
|