@openparachute/app 0.2.0-rc.4
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/.parachute/config/schema +62 -0
- package/.parachute/info +14 -0
- package/.parachute/module.json +14 -0
- package/CHANGELOG.md +405 -0
- package/LICENSE +661 -0
- package/bin/parachute-app.ts +525 -0
- package/dist/admin/assets/index-BXlRNPxk.js +60 -0
- package/dist/admin/assets/index-DaGP1hmw.css +1 -0
- package/dist/admin/index.html +14 -0
- package/package.json +51 -0
- package/src/admin-routes.ts +884 -0
- package/src/auth.ts +212 -0
- package/src/bootstrap.ts +153 -0
- package/src/cache-headers.ts +106 -0
- package/src/config.ts +289 -0
- package/src/dcr.ts +334 -0
- package/src/dev-injection.ts +166 -0
- package/src/dev-mode.ts +205 -0
- package/src/dev-routes.ts +380 -0
- package/src/dev-watcher.ts +479 -0
- package/src/http-server.ts +533 -0
- package/src/index.ts +394 -0
- package/src/meta-schema.ts +662 -0
- package/src/npm-fetch.ts +320 -0
- package/src/operator-token.ts +95 -0
- package/src/provision-schema.ts +180 -0
- package/src/self-register.ts +155 -0
- package/src/services-manifest.ts +104 -0
- package/src/ui-registry.ts +202 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading for parachute-app.
|
|
3
|
+
*
|
|
4
|
+
* Reads `$PARACHUTE_HOME/app/config.json` (default `~/.parachute/app/config.json`).
|
|
5
|
+
* Validates the shape against the on-disk Draft-07 schema in
|
|
6
|
+
* `.parachute/config/schema`. Missing-file is OK at MVP — app falls through
|
|
7
|
+
* to defaults so a fresh install can `parachute-app serve` without an explicit
|
|
8
|
+
* config step. Malformed JSON or wrong-typed fields are fail-fast.
|
|
9
|
+
*
|
|
10
|
+
* `PARACHUTE_HOME` env var overrides the parent directory — same convention
|
|
11
|
+
* every committed-core module uses (vault, runner, scribe, agent).
|
|
12
|
+
*
|
|
13
|
+
* No secrets live in app's config today, so there's no SecretsStore lift here
|
|
14
|
+
* (in contrast to runner's `vault_token` envelope). `auto_register_oauth_clients`
|
|
15
|
+
* is the closest thing to a credential-adjacent toggle and it lives in plaintext.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
|
|
22
|
+
export type AppConfig = {
|
|
23
|
+
/** Hub origin used for DCR registration in Phase 1.2+. */
|
|
24
|
+
hub_url: string;
|
|
25
|
+
/** Whether `add` triggers automatic DCR registration with hub. */
|
|
26
|
+
auto_register_oauth_clients: boolean;
|
|
27
|
+
/** Global kill switch — daemon stays running but unmounts all UIs. */
|
|
28
|
+
disabled: boolean;
|
|
29
|
+
/** Fallback scopes for UIs whose meta.json omits `scopes_required`. */
|
|
30
|
+
default_scope_required: string[];
|
|
31
|
+
/** Whether `parachute-app dev <name>` is permitted. */
|
|
32
|
+
dev_mode_allowed: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* First-boot bootstrap. When `uis/` is empty on `serve` startup, apps
|
|
35
|
+
* auto-installs each entry in `apps` via the same npm-fetch pipeline
|
|
36
|
+
* `parachute-app add` uses. Defaults to `{enabled: true, apps:
|
|
37
|
+
* ["@openparachute/notes-ui"]}` — the friend-deploy story is "spin up
|
|
38
|
+
* a hub, get Notes for free."
|
|
39
|
+
*
|
|
40
|
+
* Operators who want a different default (or no default) can flip
|
|
41
|
+
* `enabled: false` or set `apps: []`. Per design doc Section 16:
|
|
42
|
+
* Notes is the canonical first app installed under parachute-app.
|
|
43
|
+
*/
|
|
44
|
+
bootstrap_default_apps: {
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
apps: string[];
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* When a UI's meta.json declares `required_schema`, auto-provision
|
|
50
|
+
* its tags in vault at install time (and on manual re-trigger via
|
|
51
|
+
* `POST /app/<name>/provision-schema`). Best-effort: errors log +
|
|
52
|
+
* warn but don't fail the install. Default true. Per patterns#57.
|
|
53
|
+
*/
|
|
54
|
+
auto_provision_required_schema: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export class ConfigError extends Error {
|
|
58
|
+
override name = "ConfigError" as const;
|
|
59
|
+
readonly path: string;
|
|
60
|
+
constructor(message: string, configPath: string) {
|
|
61
|
+
super(`${message} (config: ${configPath})`);
|
|
62
|
+
this.path = configPath;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve `$PARACHUTE_HOME/app/config.json`. Honors `PARACHUTE_HOME` so
|
|
68
|
+
* sandboxes + Render deployments can redirect the location.
|
|
69
|
+
*
|
|
70
|
+
* `os.homedir()` is cached at process start on Bun; we prefer the live env var
|
|
71
|
+
* so test-time `HOME=` overrides take effect.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveConfigPath(env: Record<string, string | undefined> = process.env): string {
|
|
74
|
+
const parachuteHome = env.PARACHUTE_HOME ?? path.join(env.HOME ?? os.homedir(), ".parachute");
|
|
75
|
+
return path.join(parachuteHome, "app", "config.json");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve `$PARACHUTE_HOME/app/uis/` — the directory app scans for declared
|
|
80
|
+
* hosted UIs. Honors `PARACHUTE_HOME` for tests + sandboxes.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveUisDir(env: Record<string, string | undefined> = process.env): string {
|
|
83
|
+
const parachuteHome = env.PARACHUTE_HOME ?? path.join(env.HOME ?? os.homedir(), ".parachute");
|
|
84
|
+
return path.join(parachuteHome, "app", "uis");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Defaults baked into the schema. Kept in sync with `.parachute/config/schema`. */
|
|
88
|
+
export const DEFAULTS: AppConfig = {
|
|
89
|
+
hub_url: "http://127.0.0.1:1939",
|
|
90
|
+
auto_register_oauth_clients: true,
|
|
91
|
+
disabled: false,
|
|
92
|
+
default_scope_required: ["vault:*:read"],
|
|
93
|
+
dev_mode_allowed: true,
|
|
94
|
+
bootstrap_default_apps: {
|
|
95
|
+
enabled: true,
|
|
96
|
+
apps: ["@openparachute/notes-ui"],
|
|
97
|
+
},
|
|
98
|
+
auto_provision_required_schema: true,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type LoadConfigOpts = {
|
|
102
|
+
/** Override the config path (tests). Defaults to `resolveConfigPath()`. */
|
|
103
|
+
configPath?: string;
|
|
104
|
+
/** Logger override; default console. */
|
|
105
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load + validate app config from disk. When the file is absent, returns the
|
|
110
|
+
* built-in defaults (matches scribe's "no-config-yet means no-config-needed"
|
|
111
|
+
* behavior — app's config is all-optional). Malformed JSON or wrong-typed
|
|
112
|
+
* fields are fail-fast with a `ConfigError`.
|
|
113
|
+
*/
|
|
114
|
+
export function loadConfig(opts: LoadConfigOpts = {}): AppConfig {
|
|
115
|
+
const configPath = opts.configPath ?? resolveConfigPath();
|
|
116
|
+
const logger = opts.logger ?? console;
|
|
117
|
+
|
|
118
|
+
if (!existsSync(configPath)) {
|
|
119
|
+
logger.log(`[app] config file not found at ${configPath}; using defaults`);
|
|
120
|
+
return cloneDefaults();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let raw: Record<string, unknown>;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
126
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
127
|
+
throw new ConfigError("config root must be a JSON object", configPath);
|
|
128
|
+
}
|
|
129
|
+
raw = parsed as Record<string, unknown>;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e instanceof ConfigError) throw e;
|
|
132
|
+
throw new ConfigError(`failed to parse JSON: ${(e as Error).message}`, configPath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return validateConfig(raw, configPath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validate a parsed config object. Exported separately so tests can exercise
|
|
140
|
+
* the shape-validation path without a tempfile round-trip.
|
|
141
|
+
*/
|
|
142
|
+
export function validateConfig(raw: unknown, configPath = "<inline>"): AppConfig {
|
|
143
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
144
|
+
throw new ConfigError("config root must be a JSON object", configPath);
|
|
145
|
+
}
|
|
146
|
+
const o = raw as Record<string, unknown>;
|
|
147
|
+
|
|
148
|
+
// hub_url — string + uri-ish; default applied when absent.
|
|
149
|
+
let hub_url = DEFAULTS.hub_url;
|
|
150
|
+
if (o.hub_url !== undefined) {
|
|
151
|
+
if (typeof o.hub_url !== "string" || o.hub_url.length === 0) {
|
|
152
|
+
throw new ConfigError("`hub_url` must be a non-empty string", configPath);
|
|
153
|
+
}
|
|
154
|
+
// Soft URI check — fail fast on obvious typos but don't be pedantic.
|
|
155
|
+
try {
|
|
156
|
+
new URL(o.hub_url);
|
|
157
|
+
} catch {
|
|
158
|
+
throw new ConfigError(`\`hub_url\` is not a valid URL: ${o.hub_url}`, configPath);
|
|
159
|
+
}
|
|
160
|
+
hub_url = stripTrailingSlash(o.hub_url);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// auto_register_oauth_clients — bool; default true.
|
|
164
|
+
let auto_register_oauth_clients = DEFAULTS.auto_register_oauth_clients;
|
|
165
|
+
if (o.auto_register_oauth_clients !== undefined) {
|
|
166
|
+
if (typeof o.auto_register_oauth_clients !== "boolean") {
|
|
167
|
+
throw new ConfigError("`auto_register_oauth_clients` must be a boolean", configPath);
|
|
168
|
+
}
|
|
169
|
+
auto_register_oauth_clients = o.auto_register_oauth_clients;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// disabled — bool; default false.
|
|
173
|
+
let disabled = DEFAULTS.disabled;
|
|
174
|
+
if (o.disabled !== undefined) {
|
|
175
|
+
if (typeof o.disabled !== "boolean") {
|
|
176
|
+
throw new ConfigError("`disabled` must be a boolean", configPath);
|
|
177
|
+
}
|
|
178
|
+
disabled = o.disabled;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// default_scope_required — array of non-empty strings; default ["vault:*:read"].
|
|
182
|
+
let default_scope_required: string[] = [...DEFAULTS.default_scope_required];
|
|
183
|
+
if (o.default_scope_required !== undefined) {
|
|
184
|
+
if (!Array.isArray(o.default_scope_required)) {
|
|
185
|
+
throw new ConfigError("`default_scope_required` must be an array of strings", configPath);
|
|
186
|
+
}
|
|
187
|
+
const items: string[] = [];
|
|
188
|
+
for (let i = 0; i < o.default_scope_required.length; i++) {
|
|
189
|
+
const v = o.default_scope_required[i];
|
|
190
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
191
|
+
throw new ConfigError(
|
|
192
|
+
`\`default_scope_required[${i}]\` must be a non-empty string`,
|
|
193
|
+
configPath,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
items.push(v);
|
|
197
|
+
}
|
|
198
|
+
default_scope_required = items;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// dev_mode_allowed — bool; default true.
|
|
202
|
+
let dev_mode_allowed = DEFAULTS.dev_mode_allowed;
|
|
203
|
+
if (o.dev_mode_allowed !== undefined) {
|
|
204
|
+
if (typeof o.dev_mode_allowed !== "boolean") {
|
|
205
|
+
throw new ConfigError("`dev_mode_allowed` must be a boolean", configPath);
|
|
206
|
+
}
|
|
207
|
+
dev_mode_allowed = o.dev_mode_allowed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// bootstrap_default_apps — { enabled: bool, apps: string[] }; defaults applied.
|
|
211
|
+
let bootstrap_default_apps = {
|
|
212
|
+
enabled: DEFAULTS.bootstrap_default_apps.enabled,
|
|
213
|
+
apps: [...DEFAULTS.bootstrap_default_apps.apps],
|
|
214
|
+
};
|
|
215
|
+
if (o.bootstrap_default_apps !== undefined) {
|
|
216
|
+
if (
|
|
217
|
+
!o.bootstrap_default_apps ||
|
|
218
|
+
typeof o.bootstrap_default_apps !== "object" ||
|
|
219
|
+
Array.isArray(o.bootstrap_default_apps)
|
|
220
|
+
) {
|
|
221
|
+
throw new ConfigError("`bootstrap_default_apps` must be an object", configPath);
|
|
222
|
+
}
|
|
223
|
+
const bda = o.bootstrap_default_apps as Record<string, unknown>;
|
|
224
|
+
let enabled = bootstrap_default_apps.enabled;
|
|
225
|
+
if (bda.enabled !== undefined) {
|
|
226
|
+
if (typeof bda.enabled !== "boolean") {
|
|
227
|
+
throw new ConfigError("`bootstrap_default_apps.enabled` must be a boolean", configPath);
|
|
228
|
+
}
|
|
229
|
+
enabled = bda.enabled;
|
|
230
|
+
}
|
|
231
|
+
let apps: string[] = [...bootstrap_default_apps.apps];
|
|
232
|
+
if (bda.apps !== undefined) {
|
|
233
|
+
if (!Array.isArray(bda.apps)) {
|
|
234
|
+
throw new ConfigError(
|
|
235
|
+
"`bootstrap_default_apps.apps` must be an array of strings",
|
|
236
|
+
configPath,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const items: string[] = [];
|
|
240
|
+
for (let i = 0; i < bda.apps.length; i++) {
|
|
241
|
+
const v = bda.apps[i];
|
|
242
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
243
|
+
throw new ConfigError(
|
|
244
|
+
`\`bootstrap_default_apps.apps[${i}]\` must be a non-empty string`,
|
|
245
|
+
configPath,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
items.push(v);
|
|
249
|
+
}
|
|
250
|
+
apps = items;
|
|
251
|
+
}
|
|
252
|
+
bootstrap_default_apps = { enabled, apps };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// auto_provision_required_schema — bool; default true.
|
|
256
|
+
let auto_provision_required_schema = DEFAULTS.auto_provision_required_schema;
|
|
257
|
+
if (o.auto_provision_required_schema !== undefined) {
|
|
258
|
+
if (typeof o.auto_provision_required_schema !== "boolean") {
|
|
259
|
+
throw new ConfigError("`auto_provision_required_schema` must be a boolean", configPath);
|
|
260
|
+
}
|
|
261
|
+
auto_provision_required_schema = o.auto_provision_required_schema;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
hub_url,
|
|
266
|
+
auto_register_oauth_clients,
|
|
267
|
+
disabled,
|
|
268
|
+
default_scope_required,
|
|
269
|
+
dev_mode_allowed,
|
|
270
|
+
bootstrap_default_apps,
|
|
271
|
+
auto_provision_required_schema,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Fresh deep-copy of `DEFAULTS` — used when no config file exists. */
|
|
276
|
+
function cloneDefaults(): AppConfig {
|
|
277
|
+
return {
|
|
278
|
+
...DEFAULTS,
|
|
279
|
+
default_scope_required: [...DEFAULTS.default_scope_required],
|
|
280
|
+
bootstrap_default_apps: {
|
|
281
|
+
enabled: DEFAULTS.bootstrap_default_apps.enabled,
|
|
282
|
+
apps: [...DEFAULTS.bootstrap_default_apps.apps],
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function stripTrailingSlash(url: string): string {
|
|
288
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
289
|
+
}
|
package/src/dcr.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic Client Registration (RFC 7591) for hosted UIs.
|
|
3
|
+
*
|
|
4
|
+
* When `POST /app/add` succeeds with `config.auto_register_oauth_clients = true`,
|
|
5
|
+
* parachute-app registers the new UI as an OAuth public client of hub:
|
|
6
|
+
*
|
|
7
|
+
* POST <hub_url>/oauth/register
|
|
8
|
+
* Authorization: Bearer <operator-token> (sourced via operator-token.ts)
|
|
9
|
+
* Content-Type: application/json
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "client_name": "<displayName>",
|
|
13
|
+
* "redirect_uris": ["<hub_url><meta.path>/", "<hub_url><meta.path>/oauth-callback"],
|
|
14
|
+
* "scope": "<scopes_required joined by space>",
|
|
15
|
+
* "token_endpoint_auth_method": "none",
|
|
16
|
+
* "grant_types": ["authorization_code"],
|
|
17
|
+
* "response_types": ["code"]
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Hub returns `201 Created` with `{client_id, ...}`. We persist the `client_id`
|
|
21
|
+
* in `~/.parachute/app/uis/<name>/.oauth-client.json` (chmod 0o600).
|
|
22
|
+
*
|
|
23
|
+
* Auth posture for the call:
|
|
24
|
+
* - When an operator bearer is available (`PARACHUTE_HUB_TOKEN` env or
|
|
25
|
+
* `~/.parachute/operator.token`), the bearer is sent and the resulting
|
|
26
|
+
* client lands `approved` (no human follow-up needed).
|
|
27
|
+
* - When no operator bearer is available, the call still works — the client
|
|
28
|
+
* lands `pending`, and the operator has to click approve in hub admin.
|
|
29
|
+
* We surface this in the response so the CLI/admin SPA can render the
|
|
30
|
+
* hint.
|
|
31
|
+
*
|
|
32
|
+
* Errors:
|
|
33
|
+
* - Hub unreachable / 5xx → `DcrError` with `status: "hub_unreachable"`
|
|
34
|
+
* - Hub returns 4xx (bad shape, scopes hub doesn't recognize, etc.) → the
|
|
35
|
+
* full hub body is folded into the error so the operator sees what hub
|
|
36
|
+
* said back. The caller decides whether to abort the add or proceed
|
|
37
|
+
* without OAuth (UI still mounts, OAuth dance just fails at runtime).
|
|
38
|
+
* - Local file write failure → propagated; the caller's `POST /app/add`
|
|
39
|
+
* surfaces it.
|
|
40
|
+
*
|
|
41
|
+
* Revocation on remove. There's no spec'd RFC 7591 client-deletion endpoint
|
|
42
|
+
* that hub implements universally; for now `removeOauthClient()` is a no-op
|
|
43
|
+
* locally (just deletes the `.oauth-client.json` file), and the orphaned
|
|
44
|
+
* client record stays in hub's DB until an operator runs `parachute auth
|
|
45
|
+
* revoke-client <id>` or hub adds an `RFC 7592` /oauth/clients/<id> DELETE.
|
|
46
|
+
* If hub later adds the endpoint, this function fires a best-effort DELETE
|
|
47
|
+
* and ignores 404s.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
51
|
+
import * as path from "node:path";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Persisted client record. Saved at `~/.parachute/app/uis/<name>/.oauth-client.json`.
|
|
55
|
+
* `same_hub: true` is hub's auto-trust flag (per design doc section 6) — we
|
|
56
|
+
* carry it through so the admin SPA can show "auto-trusted" for these clients.
|
|
57
|
+
*/
|
|
58
|
+
export type OauthClientRecord = {
|
|
59
|
+
client_id: string;
|
|
60
|
+
client_name: string;
|
|
61
|
+
redirect_uris: string[];
|
|
62
|
+
scope: string;
|
|
63
|
+
status?: string;
|
|
64
|
+
registered_at: string;
|
|
65
|
+
hub_url: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shape of a DCR-register response from hub. Per RFC 7591 + hub's actual
|
|
70
|
+
* response (see `handleRegister` in parachute-hub/src/oauth-handlers.ts).
|
|
71
|
+
*/
|
|
72
|
+
export type DcrRegisterResponse = {
|
|
73
|
+
client_id: string;
|
|
74
|
+
client_name?: string;
|
|
75
|
+
redirect_uris: string[];
|
|
76
|
+
scope?: string;
|
|
77
|
+
grant_types: string[];
|
|
78
|
+
response_types: string[];
|
|
79
|
+
token_endpoint_auth_method: string;
|
|
80
|
+
client_id_issued_at: number;
|
|
81
|
+
status?: string;
|
|
82
|
+
client_secret?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Loose fetch signature. Bun's stricter `typeof fetch` requires the
|
|
87
|
+
* `preconnect` static method we never use; this alias keeps test stubs
|
|
88
|
+
* one-arg-and-init clean.
|
|
89
|
+
*/
|
|
90
|
+
export type FetchFn = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
91
|
+
|
|
92
|
+
export class DcrError extends Error {
|
|
93
|
+
override name = "DcrError" as const;
|
|
94
|
+
readonly status: "hub_unreachable" | "hub_rejected" | "invalid_response";
|
|
95
|
+
readonly hubResponseStatus?: number;
|
|
96
|
+
readonly hubResponseBody?: string;
|
|
97
|
+
constructor(
|
|
98
|
+
message: string,
|
|
99
|
+
status: "hub_unreachable" | "hub_rejected" | "invalid_response",
|
|
100
|
+
extra: { hubResponseStatus?: number; hubResponseBody?: string } = {},
|
|
101
|
+
) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.status = status;
|
|
104
|
+
this.hubResponseStatus = extra.hubResponseStatus;
|
|
105
|
+
this.hubResponseBody = extra.hubResponseBody;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type RegisterOauthClientOpts = {
|
|
110
|
+
/** Hub origin (e.g. `http://127.0.0.1:1939`). Stripped of trailing slash. */
|
|
111
|
+
hubUrl: string;
|
|
112
|
+
/** Human label — typically `meta.displayName`. */
|
|
113
|
+
clientName: string;
|
|
114
|
+
/** Where hub redirects back to after consent. Must be absolute (hub-origin-prefixed). */
|
|
115
|
+
redirectUris: string[];
|
|
116
|
+
/** Space-separated scope list — derived from `meta.scopes_required`. */
|
|
117
|
+
scopes: string[];
|
|
118
|
+
/** Operator bearer when available — sourced via `operator-token.ts`. */
|
|
119
|
+
operatorToken?: string;
|
|
120
|
+
/** Injected fetch (tests). Defaults to global `fetch`. */
|
|
121
|
+
fetchFn?: FetchFn;
|
|
122
|
+
/** Logger override; default console. */
|
|
123
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Hit hub's `/oauth/register` and return the parsed response.
|
|
128
|
+
*
|
|
129
|
+
* Failure modes (each thrown as `DcrError`):
|
|
130
|
+
* - Network error / hub unreachable → `status: "hub_unreachable"`
|
|
131
|
+
* - 4xx/5xx response → `status: "hub_rejected"` with body folded in
|
|
132
|
+
* - 2xx but unparseable JSON → `status: "invalid_response"`
|
|
133
|
+
*/
|
|
134
|
+
export async function registerOauthClient(
|
|
135
|
+
opts: RegisterOauthClientOpts,
|
|
136
|
+
): Promise<DcrRegisterResponse> {
|
|
137
|
+
const logger = opts.logger ?? console;
|
|
138
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
139
|
+
const hubUrl = opts.hubUrl.replace(/\/$/, "");
|
|
140
|
+
const url = `${hubUrl}/oauth/register`;
|
|
141
|
+
|
|
142
|
+
const body = {
|
|
143
|
+
client_name: opts.clientName,
|
|
144
|
+
redirect_uris: opts.redirectUris,
|
|
145
|
+
scope: opts.scopes.join(" "),
|
|
146
|
+
token_endpoint_auth_method: "none",
|
|
147
|
+
grant_types: ["authorization_code"],
|
|
148
|
+
response_types: ["code"],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const headers: Record<string, string> = {
|
|
152
|
+
"content-type": "application/json",
|
|
153
|
+
};
|
|
154
|
+
if (opts.operatorToken) {
|
|
155
|
+
headers.authorization = `Bearer ${opts.operatorToken}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let res: Response;
|
|
159
|
+
try {
|
|
160
|
+
res = await fetchFn(url, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers,
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
});
|
|
165
|
+
} catch (e) {
|
|
166
|
+
const msg = (e as Error).message;
|
|
167
|
+
logger.warn(`[app-dcr] hub unreachable at ${url}: ${msg}`);
|
|
168
|
+
throw new DcrError(
|
|
169
|
+
`hub unreachable at ${url}: ${msg}. Retry once hub is running, or set auto_register_oauth_clients=false.`,
|
|
170
|
+
"hub_unreachable",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const text = await res.text();
|
|
175
|
+
if (res.status >= 400) {
|
|
176
|
+
logger.warn(`[app-dcr] hub rejected DCR (${res.status}): ${text.slice(0, 500)}`);
|
|
177
|
+
throw new DcrError(
|
|
178
|
+
`hub rejected DCR registration (status ${res.status}): ${text.slice(0, 200)}`,
|
|
179
|
+
"hub_rejected",
|
|
180
|
+
{ hubResponseStatus: res.status, hubResponseBody: text },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let parsed: DcrRegisterResponse;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(text) as DcrRegisterResponse;
|
|
187
|
+
} catch (e) {
|
|
188
|
+
throw new DcrError(
|
|
189
|
+
`hub returned ${res.status} but body was not valid JSON: ${(e as Error).message}`,
|
|
190
|
+
"invalid_response",
|
|
191
|
+
{ hubResponseStatus: res.status, hubResponseBody: text },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (typeof parsed.client_id !== "string" || parsed.client_id.length === 0) {
|
|
196
|
+
throw new DcrError(
|
|
197
|
+
`hub returned ${res.status} but response is missing client_id`,
|
|
198
|
+
"invalid_response",
|
|
199
|
+
{ hubResponseStatus: res.status, hubResponseBody: text },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Persist the OAuth client record to disk under the UI's directory.
|
|
208
|
+
*
|
|
209
|
+
* Mode 0o600 — only the running daemon's user reads it. The client_id is
|
|
210
|
+
* not technically a secret (public OAuth clients), but mode 0o600 mirrors
|
|
211
|
+
* the operator-token-file pattern and keeps the file out of casual reads.
|
|
212
|
+
*/
|
|
213
|
+
export function writeOauthClientFile(uiDir: string, record: OauthClientRecord): string {
|
|
214
|
+
mkdirSync(uiDir, { recursive: true });
|
|
215
|
+
const filePath = path.join(uiDir, ".oauth-client.json");
|
|
216
|
+
writeFileSync(filePath, `${JSON.stringify(record, null, 2)}\n`);
|
|
217
|
+
try {
|
|
218
|
+
chmodSync(filePath, 0o600);
|
|
219
|
+
} catch {
|
|
220
|
+
// chmod may fail on Windows / odd filesystems; the file is still written.
|
|
221
|
+
}
|
|
222
|
+
return filePath;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read the persisted OAuth client record for a UI. Returns `undefined` when
|
|
227
|
+
* the file is missing (UI was added before DCR, or DCR failed).
|
|
228
|
+
*/
|
|
229
|
+
export function readOauthClientFile(uiDir: string): OauthClientRecord | undefined {
|
|
230
|
+
const filePath = path.join(uiDir, ".oauth-client.json");
|
|
231
|
+
if (!existsSync(filePath)) return undefined;
|
|
232
|
+
try {
|
|
233
|
+
const body = readFileSync(filePath, "utf8");
|
|
234
|
+
const parsed = JSON.parse(body);
|
|
235
|
+
if (
|
|
236
|
+
!parsed ||
|
|
237
|
+
typeof parsed !== "object" ||
|
|
238
|
+
Array.isArray(parsed) ||
|
|
239
|
+
typeof (parsed as Record<string, unknown>).client_id !== "string"
|
|
240
|
+
) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return parsed as OauthClientRecord;
|
|
244
|
+
} catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Best-effort revocation of a UI's OAuth client.
|
|
251
|
+
*
|
|
252
|
+
* Three pieces:
|
|
253
|
+
* 1. Try `DELETE <hub_url>/oauth/clients/<client_id>` (RFC 7592). 4xx other
|
|
254
|
+
* than 404 → log + carry on; 404 → fine, already gone.
|
|
255
|
+
* 2. Remove the local `.oauth-client.json`.
|
|
256
|
+
*
|
|
257
|
+
* Never throws — removal must complete even if hub is unreachable. Returns
|
|
258
|
+
* a status object the caller surfaces in the response.
|
|
259
|
+
*/
|
|
260
|
+
export type UnregisterResult = {
|
|
261
|
+
/** True when the local file was removed (or didn't exist to begin with). */
|
|
262
|
+
localFileRemoved: boolean;
|
|
263
|
+
/** Status of the upstream DELETE call. `"unsupported"` covers 404 from hub (the route doesn't exist yet). */
|
|
264
|
+
hubDeleteStatus: "ok" | "not_found" | "unsupported" | "error" | "unreachable" | "skipped";
|
|
265
|
+
/** Optional human-readable detail. */
|
|
266
|
+
detail?: string;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export type UnregisterOauthClientOpts = {
|
|
270
|
+
hubUrl: string;
|
|
271
|
+
clientId?: string;
|
|
272
|
+
uiDir: string;
|
|
273
|
+
operatorToken?: string;
|
|
274
|
+
fetchFn?: FetchFn;
|
|
275
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export async function unregisterOauthClient(
|
|
279
|
+
opts: UnregisterOauthClientOpts,
|
|
280
|
+
): Promise<UnregisterResult> {
|
|
281
|
+
const logger = opts.logger ?? console;
|
|
282
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
283
|
+
const hubUrl = opts.hubUrl.replace(/\/$/, "");
|
|
284
|
+
|
|
285
|
+
let hubDeleteStatus: UnregisterResult["hubDeleteStatus"] = "skipped";
|
|
286
|
+
let detail: string | undefined;
|
|
287
|
+
|
|
288
|
+
if (opts.clientId) {
|
|
289
|
+
const url = `${hubUrl}/oauth/clients/${encodeURIComponent(opts.clientId)}`;
|
|
290
|
+
const headers: Record<string, string> = {};
|
|
291
|
+
if (opts.operatorToken) headers.authorization = `Bearer ${opts.operatorToken}`;
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetchFn(url, { method: "DELETE", headers });
|
|
294
|
+
if (res.status === 204 || res.status === 200) {
|
|
295
|
+
hubDeleteStatus = "ok";
|
|
296
|
+
} else if (res.status === 404) {
|
|
297
|
+
// Hub doesn't have an RFC 7592 endpoint yet, OR the client was
|
|
298
|
+
// already removed. Either way: no work to do.
|
|
299
|
+
hubDeleteStatus = res.headers.get("content-type")?.includes("json")
|
|
300
|
+
? "not_found"
|
|
301
|
+
: "unsupported";
|
|
302
|
+
detail = "hub returned 404 — endpoint may not exist or client already gone";
|
|
303
|
+
} else if (res.status === 405) {
|
|
304
|
+
// Method not allowed — hub doesn't expose DELETE here.
|
|
305
|
+
hubDeleteStatus = "unsupported";
|
|
306
|
+
detail = `hub returned ${res.status}; DELETE not supported`;
|
|
307
|
+
} else {
|
|
308
|
+
hubDeleteStatus = "error";
|
|
309
|
+
const body = await res.text();
|
|
310
|
+
detail = `hub returned ${res.status}: ${body.slice(0, 200)}`;
|
|
311
|
+
logger.warn(`[app-dcr] revoke ${opts.clientId} failed: ${detail}`);
|
|
312
|
+
}
|
|
313
|
+
} catch (e) {
|
|
314
|
+
hubDeleteStatus = "unreachable";
|
|
315
|
+
detail = `hub unreachable: ${(e as Error).message}`;
|
|
316
|
+
logger.warn(`[app-dcr] revoke ${opts.clientId} failed: ${detail}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Always remove the local file last so a re-run of `remove` after a hub
|
|
321
|
+
// restore re-attempts the upstream delete.
|
|
322
|
+
const filePath = path.join(opts.uiDir, ".oauth-client.json");
|
|
323
|
+
let localFileRemoved = true;
|
|
324
|
+
if (existsSync(filePath)) {
|
|
325
|
+
try {
|
|
326
|
+
unlinkSync(filePath);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
localFileRemoved = false;
|
|
329
|
+
detail = `${detail ? `${detail}; ` : ""}local file unlink failed: ${(e as Error).message}`;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { localFileRemoved, hubDeleteStatus, detail };
|
|
334
|
+
}
|