@openparachute/hub 0.5.10-rc.6 → 0.5.10

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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +139 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Hub-local key/value settings (hub#268).
3
+ *
4
+ * Bare KV table backing two wizard-adjacent features:
5
+ *
6
+ * * `setup_expose_mode` — `localhost | tailnet | public`. The operator's
7
+ * "how will this hub be reached?" choice from the first-boot wizard's
8
+ * expose step. The done step reads it to surface the right reachable-at
9
+ * URL + next-step instructions.
10
+ *
11
+ * * `pending_first_client_auto_approve_until` — ISO-8601 timestamp. Set
12
+ * when the wizard finishes (60 minutes in the future); the next OAuth
13
+ * client to hit `/oauth/register` *within* that window is auto-approved
14
+ * (single-use, the value is cleared on consume). Past-due or absent
15
+ * means the standard pending-approval flow applies. Motivator: a
16
+ * canonical onboarding (install hub → wizard → install Notes →
17
+ * authorize) shouldn't bounce the operator through a manual approve
18
+ * step they just set up the hub for.
19
+ *
20
+ * Schema lives in `hub-db.ts` migration v7. This module is just the typed
21
+ * accessor — single-row reads/writes per key, no joins, no caching. The
22
+ * call frequency is low (a handful of reads on `/oauth/register` + the
23
+ * wizard's done step) so the obvious shape wins.
24
+ */
25
+ import type { Database } from "bun:sqlite";
26
+
27
+ // Adding a setting: extend this union + write a typed accessor. The table itself is generic KV.
28
+ export type HubSettingKey =
29
+ | "setup_expose_mode"
30
+ | "pending_first_client_auto_approve_until"
31
+ // hub#272: auto-minted operator token surfaced once on the wizard's
32
+ // done screen. Single-use — the done-step renderer reads + deletes the
33
+ // row so a subsequent GET (page refresh, back button) doesn't re-show
34
+ // the secret. Lives in hub_settings rather than tokens because it's a
35
+ // wizard-flow ephemeral, not a persistent issued credential — the
36
+ // mintOperatorToken call still records the jti in the `tokens`
37
+ // registry, so revocation works as usual.
38
+ | "setup_minted_token"
39
+ // hub#267: the typed vault name. Persisted at vault POST time so the
40
+ // done step can render the operator's choice in the MCP URL +
41
+ // install-command snippet without re-deriving from services.json
42
+ // (vault's first-boot may write its own paths shape that the wizard
43
+ // can't trust to match `<name>` exactly until the spawn settles).
44
+ | "setup_vault_name"
45
+ // hub#275: which dist-tag the runtime module installer uses
46
+ // (`bun add -g <pkg>@<channel>`). `"latest"` (default) tracks the
47
+ // stable channel; `"rc"` follows the release-candidate chain so
48
+ // operators on the rc cadence can pull pre-release builds without
49
+ // hand-editing the install command. Seeded from
50
+ // `PARACHUTE_MODULE_CHANNEL` on first read (operator can ship a fresh
51
+ // box with the env var set and have the row land with their preferred
52
+ // channel); after the first seed the row is source of truth and the
53
+ // env var is ignored — admin must use the SPA toggle (or
54
+ // `PUT /api/modules/channel`) to change channel.
55
+ | "module_install_channel";
56
+
57
+ export type SetupExposeMode = "localhost" | "tailnet" | "public";
58
+
59
+ /**
60
+ * Set of valid `setup_expose_mode` values. Exported so the POST handler
61
+ * + the wizard renderer can both reference the same truth.
62
+ */
63
+ export const SETUP_EXPOSE_MODES: readonly SetupExposeMode[] = ["localhost", "tailnet", "public"];
64
+
65
+ export function isSetupExposeMode(s: unknown): s is SetupExposeMode {
66
+ return typeof s === "string" && SETUP_EXPOSE_MODES.includes(s as SetupExposeMode);
67
+ }
68
+
69
+ interface Row {
70
+ value: string;
71
+ }
72
+
73
+ /**
74
+ * Read a setting's value, or undefined when absent. No type coercion —
75
+ * the caller knows what shape it expects.
76
+ */
77
+ export function getSetting(db: Database, key: HubSettingKey): string | undefined {
78
+ const row = db.query<Row, [string]>("SELECT value FROM hub_settings WHERE key = ?").get(key);
79
+ return row?.value;
80
+ }
81
+
82
+ /**
83
+ * Write (or overwrite) a setting. UPSERT semantics — the SQLite
84
+ * `ON CONFLICT(key) DO UPDATE` shape is the canonical way and works back
85
+ * to the hub's SQLite minimum. `updated_at` is bumped on every write,
86
+ * even idempotent re-writes of the same value, so an operational poll
87
+ * could distinguish stale vs fresh state.
88
+ */
89
+ export function setSetting(
90
+ db: Database,
91
+ key: HubSettingKey,
92
+ value: string,
93
+ now: () => Date = () => new Date(),
94
+ ): void {
95
+ const ts = now().toISOString();
96
+ db.prepare(
97
+ `INSERT INTO hub_settings (key, value, updated_at) VALUES (?, ?, ?)
98
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
99
+ ).run(key, value, ts);
100
+ }
101
+
102
+ /**
103
+ * Remove a setting. Idempotent — deleting an already-absent key is a
104
+ * no-op (the `auto_approve_until` consume-and-clear path relies on this
105
+ * shape so the OAuth register handler doesn't have to check existence
106
+ * twice).
107
+ */
108
+ export function deleteSetting(db: Database, key: HubSettingKey): void {
109
+ db.prepare("DELETE FROM hub_settings WHERE key = ?").run(key);
110
+ }
111
+
112
+ // --- domain helpers: auto-approve window ---------------------------------
113
+
114
+ /**
115
+ * Default window during which the first OAuth client registration is
116
+ * auto-approved after the wizard completes. The brief specifies 60
117
+ * minutes; exported as a constant so tests can clamp the clock without
118
+ * threading the magic number through every callsite.
119
+ */
120
+ export const FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS = 60 * 60 * 1000;
121
+
122
+ /**
123
+ * Open the auto-approve window. Called from the wizard's vault POST
124
+ * success path (the "wizard is now done" transition). Idempotent — if a
125
+ * prior window already exists, it's overwritten with a fresh expiry.
126
+ * That's fine: re-firing the wizard's done transition for any reason
127
+ * resets the window, which is the predictable behavior.
128
+ */
129
+ export function openFirstClientAutoApproveWindow(
130
+ db: Database,
131
+ now: () => Date = () => new Date(),
132
+ ): void {
133
+ const expires = new Date(now().getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS);
134
+ setSetting(db, "pending_first_client_auto_approve_until", expires.toISOString(), now);
135
+ }
136
+
137
+ /**
138
+ * Check whether a first-client auto-approve window is currently open
139
+ * (set and in the future). Pure read; no consumption. Used by the
140
+ * OAuth register handler to decide whether to mint `approved` vs
141
+ * `pending`.
142
+ */
143
+ export function isFirstClientAutoApproveWindowOpen(
144
+ db: Database,
145
+ now: () => Date = () => new Date(),
146
+ ): boolean {
147
+ const raw = getSetting(db, "pending_first_client_auto_approve_until");
148
+ if (!raw) return false;
149
+ const expiresAt = Date.parse(raw);
150
+ if (Number.isNaN(expiresAt)) return false;
151
+ return expiresAt > now().getTime();
152
+ }
153
+
154
+ /**
155
+ * Consume the auto-approve window. Returns true if a window was open
156
+ * and was successfully consumed; false otherwise (already expired,
157
+ * never opened, or already consumed). The window is single-use — clear
158
+ * the setting on consume so the next client falls through to the
159
+ * standard pending-approval flow.
160
+ *
161
+ * This is the canonical entry point on the OAuth register path. The
162
+ * shape is "check + consume" in one call to keep the OAuth handler
163
+ * narrow + race-free under a single-writer assumption (hub is a single
164
+ * SQLite writer).
165
+ */
166
+ export function consumeFirstClientAutoApproveWindow(
167
+ db: Database,
168
+ now: () => Date = () => new Date(),
169
+ ): boolean {
170
+ if (!isFirstClientAutoApproveWindowOpen(db, now)) return false;
171
+ deleteSetting(db, "pending_first_client_auto_approve_until");
172
+ return true;
173
+ }
174
+
175
+ // --- domain helpers: module install channel ------------------------------
176
+
177
+ /**
178
+ * Which dist-tag `bun add -g <pkg>@<channel>` should use. `"latest"`
179
+ * tracks the stable channel; `"rc"` tracks pre-release builds. Exposed
180
+ * here (not buried in api-modules-ops) because the admin SPA reads it
181
+ * via `/api/modules` and writes it via `/api/modules/channel` — the
182
+ * setting is the cross-cutting source of truth, the install path is
183
+ * one consumer.
184
+ */
185
+ export type ModuleInstallChannel = "latest" | "rc";
186
+
187
+ /** Exported so the API handler + the SPA toggle can share validation. */
188
+ export const MODULE_INSTALL_CHANNELS: readonly ModuleInstallChannel[] = ["latest", "rc"];
189
+
190
+ export function isModuleInstallChannel(s: unknown): s is ModuleInstallChannel {
191
+ return typeof s === "string" && MODULE_INSTALL_CHANNELS.includes(s as ModuleInstallChannel);
192
+ }
193
+
194
+ /**
195
+ * Env var that seeds `module_install_channel` on first read. Read only
196
+ * when the hub_settings row is absent — once the row exists, the env
197
+ * var is ignored on subsequent boots (admin must use the SPA toggle or
198
+ * the API to change channel). Lets Aaron's fresh-machine deploys ship
199
+ * with `PARACHUTE_MODULE_CHANNEL=rc` baked into the platform's env
200
+ * config without baking the channel into the binary or first-boot.
201
+ */
202
+ export const PARACHUTE_MODULE_CHANNEL_ENV = "PARACHUTE_MODULE_CHANNEL";
203
+
204
+ /** Fallback when nothing else is set — the stable channel. */
205
+ export const DEFAULT_MODULE_INSTALL_CHANNEL: ModuleInstallChannel = "latest";
206
+
207
+ /**
208
+ * Read the configured module install channel. On first call (no row in
209
+ * hub_settings), seeds from `process.env.PARACHUTE_MODULE_CHANNEL` if
210
+ * valid, otherwise defaults to `"latest"` (an invalid env value warns
211
+ * + still falls back to "latest"). After that first seed, the
212
+ * hub_settings row is source of truth.
213
+ *
214
+ * The `env` + `warn` knobs are test seams — production uses
215
+ * `process.env` + `console.warn`. Tests inject a deterministic shape so
216
+ * the warn-on-invalid branch can be asserted without console-capture.
217
+ */
218
+ export function getModuleInstallChannel(
219
+ db: Database,
220
+ opts: {
221
+ env?: NodeJS.ProcessEnv;
222
+ warn?: (msg: string) => void;
223
+ } = {},
224
+ ): ModuleInstallChannel {
225
+ const existing = getSetting(db, "module_install_channel");
226
+ if (existing !== undefined) {
227
+ // Row already seeded — trust it. If somehow corrupted (manual sqlite
228
+ // edit, schema drift), fall back to "latest" silently rather than
229
+ // crashing the install path. Re-seeding the row is left to the
230
+ // admin's explicit setModuleInstallChannel call.
231
+ if (isModuleInstallChannel(existing)) return existing;
232
+ return DEFAULT_MODULE_INSTALL_CHANNEL;
233
+ }
234
+ const env = opts.env ?? process.env;
235
+ const warn = opts.warn ?? ((msg: string) => console.warn(msg));
236
+ const fromEnv = env[PARACHUTE_MODULE_CHANNEL_ENV];
237
+ let seed: ModuleInstallChannel = DEFAULT_MODULE_INSTALL_CHANNEL;
238
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
239
+ if (isModuleInstallChannel(fromEnv)) {
240
+ seed = fromEnv;
241
+ } else {
242
+ warn(
243
+ `[hub-settings] ${PARACHUTE_MODULE_CHANNEL_ENV}="${fromEnv}" is not a valid channel — expected one of ${MODULE_INSTALL_CHANNELS.join(", ")}. Falling back to "${DEFAULT_MODULE_INSTALL_CHANNEL}".`,
244
+ );
245
+ }
246
+ }
247
+ setSetting(db, "module_install_channel", seed);
248
+ return seed;
249
+ }
250
+
251
+ /**
252
+ * Write the module install channel. Validated by the caller (the API
253
+ * handler + the SPA already constrain to the union); the function
254
+ * itself only accepts the typed shape, so a TypeScript-clean callsite
255
+ * can't write a malformed value.
256
+ */
257
+ export function setModuleInstallChannel(db: Database, channel: ModuleInstallChannel): void {
258
+ setSetting(db, "module_install_channel", channel);
259
+ }
package/src/hub.ts CHANGED
@@ -487,16 +487,41 @@ const HTML_TEMPLATE = `<!doctype html>
487
487
  // even if the well-known fetch is slow or fails.
488
488
  renderAdmin();
489
489
 
490
- try {
491
- const wk = await fetch('/.well-known/parachute.json', { credentials: 'omit' });
492
- if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
493
- const doc = await wk.json();
494
- const services = Array.isArray(doc.services) ? doc.services : [];
495
- renderServices(services);
496
- } catch (err) {
497
- servicesGrid.innerHTML = '<div class="error">Could not load services: ' +
498
- (err && err.message ? err.message : String(err)) + '</div>';
490
+ // Fetch services and render. cache 'no-store' on the fetch matters
491
+ // here: without it, the browser's HTTP cache returns the stale
492
+ // services list the next time the operator clicks back to / after
493
+ // installing a module via /admin/modules. Server-side also sets
494
+ // cache-control no-store on the well-known doc; belt-and-suspenders
495
+ // since older browsers (and some intermediaries) ignore one or the
496
+ // other (hub#268 Item 1).
497
+ async function loadServices() {
498
+ try {
499
+ const wk = await fetch('/.well-known/parachute.json', {
500
+ credentials: 'omit',
501
+ cache: 'no-store',
502
+ });
503
+ if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
504
+ const doc = await wk.json();
505
+ const services = Array.isArray(doc.services) ? doc.services : [];
506
+ renderServices(services);
507
+ } catch (err) {
508
+ servicesGrid.innerHTML = '<div class="error">Could not load services: ' +
509
+ (err && err.message ? err.message : String(err)) + '</div>';
510
+ }
499
511
  }
512
+
513
+ // Re-fetch on pageshow (covers the bfcache-restore path: when an
514
+ // operator clicks back from /admin/modules to / the browser may
515
+ // restore the prior DOM without re-running the IIFE, leaving stale
516
+ // tiles). The event's persisted flag is the bfcache discriminator —
517
+ // true when the page was rehydrated from cache, false on a fresh
518
+ // load. On fresh load the initial loadServices() below already ran,
519
+ // so we only re-fetch when persisted is true.
520
+ window.addEventListener('pageshow', (e) => {
521
+ if (e.persisted) void loadServices();
522
+ });
523
+
524
+ void loadServices();
500
525
  })();
501
526
  </script>
502
527
  </body>
package/src/jwt-sign.ts CHANGED
@@ -47,6 +47,20 @@ export interface SignAccessTokenOpts {
47
47
  * or thread it from `OAuthDeps.issuer`.
48
48
  */
49
49
  issuer: string;
50
+ /**
51
+ * Per-user vault pin — the multi-user Phase 1 (design
52
+ * [`2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/))
53
+ * vault_scope claim. Non-empty for non-admin users (a single-element list
54
+ * naming their `assigned_vault`); empty `[]` for admin users (the "no
55
+ * per-user vault restriction" sentinel — admins can request any vault on
56
+ * the hub via the consent picker). Always emitted as a claim — defaults
57
+ * to `[]` when callers omit — so a downstream consumer (PR 5's
58
+ * scope-guard at vault / notes / scribe) can unambiguously read it
59
+ * without distinguishing "absent" from "empty." Phase 1 always has
60
+ * length ≤1; the list shape carries Phase 2 multi-vault forward without
61
+ * a wire-shape change.
62
+ */
63
+ vaultScope?: string[];
50
64
  /** Override the jti (defaults to random base64url(16)). Used by tests. */
51
65
  jti?: string;
52
66
  /**
@@ -60,7 +74,8 @@ export interface SignAccessTokenOpts {
60
74
  * `pa_scope_set` (which scope-set the token was minted under) so an
61
75
  * auto-rotation can preserve the operator's chosen narrowing across mints.
62
76
  * Reserved claims (`scope`, `client_id`, `sub`, `iss`, `iat`, `exp`, `aud`,
63
- * `jti`) are owned by this function and overwritten if passed here.
77
+ * `jti`, `vault_scope`) are owned by this function and overwritten if
78
+ * passed here.
64
79
  */
65
80
  extraClaims?: Record<string, unknown>;
66
81
  }
@@ -85,6 +100,7 @@ export async function signAccessToken(
85
100
  ...(opts.extraClaims ?? {}),
86
101
  scope: opts.scopes.join(" "),
87
102
  client_id: opts.clientId,
103
+ vault_scope: opts.vaultScope ?? [],
88
104
  })
89
105
  .setProtectedHeader({ alg: SIGNING_ALGORITHM, kid: key.kid })
90
106
  .setSubject(opts.sub)