@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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- 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
|
+
}
|