@openparachute/hub 0.5.7 → 0.5.10-rc.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 (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -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
+ }