@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.
@@ -0,0 +1,884 @@
1
+ /**
2
+ * Admin endpoints — Phase 1.2 of parachute-app.
3
+ *
4
+ * Routes implemented here:
5
+ *
6
+ * GET /app/list — list mounted UIs (app:read or app:admin)
7
+ * POST /app/add — register a new UI (app:admin)
8
+ * DELETE /app/<name> — unregister + remove (app:admin)
9
+ * POST /app/<name>/reload — re-scan from disk (app:admin)
10
+ * GET /app/<name>/info — full info for one UI (app:read or app:admin)
11
+ * GET /app/<name>/oauth-client — public client_id discovery (UNAUTHENTICATED)
12
+ *
13
+ * The handlers operate on `AppState` (the same mutable state object the HTTP
14
+ * server's `handle()` closes over). Every state-mutating handler:
15
+ *
16
+ * 1. Resolves the on-disk change (`uis/<name>/` write or unlink).
17
+ * 2. Re-runs `scanUis()` to rebuild the in-memory list.
18
+ * 3. Swaps `state.registeredUis` + `state.skippedUis` atomically.
19
+ * 4. Re-runs `selfRegister` to refresh the `uis` map in services.json.
20
+ *
21
+ * That keeps the routing layer's "find the matching UI" lookup pointed at
22
+ * a fresh source of truth without restarting the daemon. Hub reads
23
+ * services.json per-request (post-hub#292) so the per-UI sub-units surface
24
+ * in discovery on the next request.
25
+ *
26
+ * Auth model:
27
+ * - `app:read` ≤ `app:admin` (admin implies read). Enforced in `auth.ts`.
28
+ * - `oauth-client` is unauthenticated by design — the UI's JS reads it at
29
+ * page load before any token exists. The `client_id` is public OAuth
30
+ * metadata (RFC 7591 public client + PKCE).
31
+ *
32
+ * Path traversal defense: `<name>` is constrained to `[a-z][a-z0-9-]*` at
33
+ * every layer — meta.json validation, `parseAddRequest`, and the URL
34
+ * extractor below all reject anything else, so a request like
35
+ * `DELETE /app/..%2Fetc/passwd` falls through to a 404.
36
+ */
37
+
38
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
39
+ import * as path from "node:path";
40
+
41
+ import { SCOPE_ADMIN, SCOPE_READ, enforceScope as defaultEnforceScope } from "./auth.ts";
42
+ import type { AppConfig } from "./config.ts";
43
+ import { resolveUisDir } from "./config.ts";
44
+ import {
45
+ DcrError,
46
+ type OauthClientRecord,
47
+ readOauthClientFile,
48
+ registerOauthClient,
49
+ unregisterOauthClient,
50
+ writeOauthClientFile,
51
+ } from "./dcr.ts";
52
+ import { InvalidMetaError, NAME_PATTERN, PATH_PATTERN, parseMeta } from "./meta-schema.ts";
53
+ import { NpmFetchError, copyDir, fetchNpmPackage, parseNpmSpec } from "./npm-fetch.ts";
54
+ import { readOperatorToken } from "./operator-token.ts";
55
+ import { type ProvisionSchemaResult, provisionSchemaForUi } from "./provision-schema.ts";
56
+ import { resolveProjectRoot, selfRegister } from "./self-register.ts";
57
+ import { type RegisteredUi, type SkippedUi, scanUis } from "./ui-registry.ts";
58
+
59
+ import type { AppState } from "./http-server.ts";
60
+
61
+ /**
62
+ * Subset of `AppState` admin handlers mutate. Spelled separately so a unit
63
+ * test can pass a synthetic state without needing the full http-server
64
+ * dependency closure.
65
+ */
66
+ export type AdminMutableState = Pick<AppState, "config" | "registeredUis" | "skippedUis">;
67
+
68
+ /**
69
+ * Test-only seam: override the auth-enforcement step. Production callers do
70
+ * NOT pass this; the real `enforceScope` from `auth.ts` is used. Tests pass
71
+ * a short-circuit that returns either a Response (forwarded) or a granted-
72
+ * scopes object (allowed) without minting a real hub JWT.
73
+ */
74
+ export type EnforceScopeFn = (
75
+ req: Request,
76
+ requiredScope: "app:admin" | "app:read",
77
+ ) => Promise<Response | { scopes: readonly string[] }>;
78
+
79
+ export type AdminHandlerOpts = {
80
+ /** Live mutable state — admin handlers re-scan + swap in-place. */
81
+ state: AdminMutableState;
82
+ /** Override the uis-dir location (tests). Defaults to `resolveUisDir(state.config)`. */
83
+ uisDir?: string;
84
+ /** Override the services.json path (tests). Defaults to `resolveManifestPath()`. */
85
+ manifestPath?: string;
86
+ /** Injected fetch for DCR calls (tests). */
87
+ fetchFn?: import("./dcr.ts").FetchFn;
88
+ /** Override operator token env / path (tests). */
89
+ operatorTokenOverride?: () => string | undefined;
90
+ /** Override npm-fetch spawner (tests). */
91
+ npmSpawnFn?: import("./npm-fetch.ts").NpmSpawnFn;
92
+ /** Logger override; default console. */
93
+ logger?: Pick<Console, "log" | "warn" | "error">;
94
+ /** Skip self-register refresh after a mutation (tests). */
95
+ skipSelfRegisterRefresh?: boolean;
96
+ /** Test-only seam: replace `enforceScope` with a stub. */
97
+ enforceScopeFn?: EnforceScopeFn;
98
+ };
99
+
100
+ type RouteOutcome = { handled: false } | { handled: true; response: Promise<Response> | Response };
101
+
102
+ /**
103
+ * Route a request to an admin handler. Returns `{handled: false}` when the
104
+ * request is not an admin route — the caller falls through to its next
105
+ * matcher (per-UI static asset serving, then 404).
106
+ *
107
+ * Pattern matches the runner's `handle()` dispatch — short table at the
108
+ * top, fall through is a 404.
109
+ */
110
+ export function routeAdmin(req: Request, opts: AdminHandlerOpts): RouteOutcome {
111
+ const url = new URL(req.url);
112
+ const pathname = url.pathname;
113
+ const method = req.method;
114
+
115
+ // GET /app/list
116
+ if (pathname === "/app/list" && method === "GET") {
117
+ return { handled: true, response: handleList(req, opts) };
118
+ }
119
+
120
+ // POST /app/add
121
+ if (pathname === "/app/add" && method === "POST") {
122
+ return { handled: true, response: handleAdd(req, opts) };
123
+ }
124
+
125
+ // GET /app/<name>/oauth-client — unauthenticated.
126
+ const oauthMatch = pathname.match(/^\/app\/([a-z][a-z0-9-]*)\/oauth-client$/);
127
+ if (oauthMatch && method === "GET") {
128
+ return { handled: true, response: handleOauthClient(oauthMatch[1]!, opts) };
129
+ }
130
+
131
+ // GET /app/<name>/info
132
+ const infoMatch = pathname.match(/^\/app\/([a-z][a-z0-9-]*)\/info$/);
133
+ if (infoMatch && method === "GET") {
134
+ return { handled: true, response: handleInfo(req, infoMatch[1]!, opts) };
135
+ }
136
+
137
+ // POST /app/<name>/reload
138
+ const reloadMatch = pathname.match(/^\/app\/([a-z][a-z0-9-]*)\/reload$/);
139
+ if (reloadMatch && method === "POST") {
140
+ return { handled: true, response: handleReload(req, reloadMatch[1]!, opts) };
141
+ }
142
+
143
+ // POST /app/<name>/provision-schema — Phase 2.1 manual re-trigger.
144
+ const provisionMatch = pathname.match(/^\/app\/([a-z][a-z0-9-]*)\/provision-schema$/);
145
+ if (provisionMatch && method === "POST") {
146
+ return { handled: true, response: handleProvisionSchema(req, provisionMatch[1]!, opts) };
147
+ }
148
+
149
+ // DELETE /app/<name>
150
+ const deleteMatch = pathname.match(/^\/app\/([a-z][a-z0-9-]*)$/);
151
+ if (deleteMatch && method === "DELETE") {
152
+ return { handled: true, response: handleDelete(req, deleteMatch[1]!, opts) };
153
+ }
154
+
155
+ return { handled: false };
156
+ }
157
+
158
+ /**
159
+ * Run the auth gate. Uses `opts.enforceScopeFn` when supplied (tests),
160
+ * otherwise calls the production `enforceScope` with the daemon's hub URL.
161
+ */
162
+ function runEnforce(
163
+ req: Request,
164
+ scope: typeof SCOPE_ADMIN | typeof SCOPE_READ,
165
+ opts: AdminHandlerOpts,
166
+ ): Promise<Response | { scopes: readonly string[] }> {
167
+ if (opts.enforceScopeFn) return opts.enforceScopeFn(req, scope);
168
+ return defaultEnforceScope(req, scope, { hubUrl: opts.state.config.hub_url });
169
+ }
170
+
171
+ // --- /app/list -----------------------------------------------------------
172
+
173
+ async function handleList(req: Request, opts: AdminHandlerOpts): Promise<Response> {
174
+ const auth = await runEnforce(req, SCOPE_READ, opts);
175
+ if (auth instanceof Response) return auth;
176
+ return Response.json({
177
+ uis: opts.state.registeredUis.map((u) => serializeUi(u)),
178
+ skipped: opts.state.skippedUis,
179
+ });
180
+ }
181
+
182
+ function serializeUi(u: RegisteredUi): SerializedUi {
183
+ const oauth = readOauthClientFile(u.uiDir);
184
+ return {
185
+ name: u.meta.name,
186
+ dirName: u.dirName,
187
+ displayName: u.meta.displayName,
188
+ tagline: u.meta.tagline,
189
+ path: u.meta.path,
190
+ version: u.meta.version,
191
+ iconUrl: u.meta.iconUrl,
192
+ scopes_required: u.meta.scopes_required,
193
+ pwa: u.meta.pwa,
194
+ public: u.meta.public,
195
+ status: "active" as const,
196
+ oauthClientId: oauth?.client_id,
197
+ oauthStatus: oauth?.status,
198
+ // Surface required_schema (patterns#57) so the admin SPA can render
199
+ // a "Schema requirements" expandable section per row. Phase 2.0 is
200
+ // display-only; auto-provisioning lands in Phase 2.1+.
201
+ required_schema: u.meta.required_schema,
202
+ };
203
+ }
204
+
205
+ export type SerializedUi = {
206
+ name: string;
207
+ dirName: string;
208
+ displayName: string;
209
+ tagline?: string;
210
+ path: string;
211
+ version?: string;
212
+ iconUrl?: string;
213
+ scopes_required: string[];
214
+ pwa: boolean;
215
+ public: boolean;
216
+ status: "active";
217
+ oauthClientId?: string;
218
+ oauthStatus?: string;
219
+ /**
220
+ * Optional declaration of vault schema this app needs to function.
221
+ * Mirrors the UiMeta field of the same name (see `meta-schema.ts`).
222
+ * Phase 2.0: display-only in admin SPA; Phase 2.1+ auto-provisions.
223
+ */
224
+ required_schema?: import("./meta-schema.ts").RequiredSchemaDeclaration;
225
+ };
226
+
227
+ // --- /app/<name>/info ----------------------------------------------------
228
+
229
+ async function handleInfo(req: Request, name: string, opts: AdminHandlerOpts): Promise<Response> {
230
+ const auth = await runEnforce(req, SCOPE_READ, opts);
231
+ if (auth instanceof Response) return auth;
232
+
233
+ const ui = opts.state.registeredUis.find((u) => u.meta.name === name);
234
+ if (!ui) {
235
+ return Response.json({ error: "not_found", message: `no UI named "${name}"` }, { status: 404 });
236
+ }
237
+ const oauth = readOauthClientFile(ui.uiDir);
238
+ return Response.json({
239
+ ui: serializeUi(ui),
240
+ meta: ui.meta,
241
+ paths: {
242
+ uiDir: ui.uiDir,
243
+ distDir: ui.distDir,
244
+ },
245
+ oauth_client: oauth ?? null,
246
+ });
247
+ }
248
+
249
+ // --- /app/<name>/oauth-client (UNAUTHENTICATED) --------------------------
250
+
251
+ async function handleOauthClient(name: string, opts: AdminHandlerOpts): Promise<Response> {
252
+ const ui = opts.state.registeredUis.find((u) => u.meta.name === name);
253
+ if (!ui) {
254
+ return Response.json({ error: "not_found", message: `no UI named "${name}"` }, { status: 404 });
255
+ }
256
+ const oauth = readOauthClientFile(ui.uiDir);
257
+ if (!oauth) {
258
+ return Response.json(
259
+ {
260
+ error: "not_found",
261
+ message: `UI "${name}" has no registered OAuth client; either DCR was disabled or hub was unreachable at add time`,
262
+ },
263
+ { status: 404 },
264
+ );
265
+ }
266
+ return Response.json({
267
+ client_id: oauth.client_id,
268
+ hub_url: oauth.hub_url,
269
+ scope: oauth.scope,
270
+ redirect_uris: oauth.redirect_uris,
271
+ });
272
+ }
273
+
274
+ // --- POST /app/add -------------------------------------------------------
275
+
276
+ export type AddRequestBody = {
277
+ /** Local path OR npm package specifier. Required. */
278
+ source: string;
279
+ /** UI name. When `source` is a local path with no meta.json, this is required. */
280
+ name?: string;
281
+ /** Mount path under `/app/`. Same requirement as `name`. */
282
+ path?: string;
283
+ /** Override meta.json's displayName. */
284
+ displayName?: string;
285
+ /** Override meta.json's tagline. */
286
+ tagline?: string;
287
+ /** Override meta.json's scopes_required. */
288
+ scopes_required?: string[];
289
+ /** Override meta.json's vault_default. */
290
+ vault_default?: string;
291
+ /** Force reinstall over an existing UI of the same name. */
292
+ force?: boolean;
293
+ };
294
+
295
+ async function handleAdd(req: Request, opts: AdminHandlerOpts): Promise<Response> {
296
+ const auth = await runEnforce(req, SCOPE_ADMIN, opts);
297
+ if (auth instanceof Response) return auth;
298
+
299
+ let body: AddRequestBody;
300
+ try {
301
+ body = (await req.json()) as AddRequestBody;
302
+ } catch (e) {
303
+ return Response.json({ error: "invalid_json", message: (e as Error).message }, { status: 400 });
304
+ }
305
+
306
+ const outcome = await addUiInternal(body, opts);
307
+ return outcome.response;
308
+ }
309
+
310
+ /**
311
+ * Result envelope from `addUiInternal`. `response` is always set so the
312
+ * HTTP handler can return it directly. On success, `added` carries the
313
+ * post-scan `RegisteredUi` so callers (bootstrap, the schema-
314
+ * provisioner) can chain follow-up work without re-reading state.
315
+ */
316
+ export type AddUiInternalResult = {
317
+ response: Response;
318
+ /** The newly-registered UI, when the add succeeded. */
319
+ added?: RegisteredUi;
320
+ /** The OAuth record stamped on disk, when DCR ran + succeeded. */
321
+ oauthRecord?: OauthClientRecord;
322
+ };
323
+
324
+ /**
325
+ * Core add-a-UI flow — extracted from `handleAdd` so it's callable
326
+ * outside the HTTP path (bootstrap, schema-provisioner). The HTTP
327
+ * handler parses the body + delegates here; bootstrap constructs the
328
+ * body in-process. The auth gate stays in `handleAdd` — internal
329
+ * callers are already trusted (they're inside the daemon process).
330
+ *
331
+ * Returns an `AddUiInternalResult` whose `.response` mirrors what the
332
+ * HTTP endpoint returns; tests + bootstrap can read the parsed JSON to
333
+ * branch on success/failure without unmarshalling a Response a second
334
+ * time.
335
+ */
336
+ export async function addUiInternal(
337
+ body: AddRequestBody,
338
+ opts: AdminHandlerOpts,
339
+ ): Promise<AddUiInternalResult> {
340
+ if (typeof body.source !== "string" || body.source.length === 0) {
341
+ return {
342
+ response: Response.json(
343
+ { error: "bad_request", message: "`source` is required (string)" },
344
+ { status: 400 },
345
+ ),
346
+ };
347
+ }
348
+
349
+ // Identify whether `source` is a local path or an npm spec. Path takes
350
+ // precedence — if it points at a real directory we treat it as filesystem.
351
+ // Otherwise we try the npm spec pattern.
352
+ const sourceIsExistingPath = existsSync(body.source);
353
+ const npmSpec = sourceIsExistingPath ? undefined : parseNpmSpec(body.source);
354
+
355
+ if (!sourceIsExistingPath && !npmSpec) {
356
+ return {
357
+ response: Response.json(
358
+ {
359
+ error: "bad_source",
360
+ message: `\"${body.source}\" is neither an existing local path nor a valid npm package specifier`,
361
+ },
362
+ { status: 400 },
363
+ ),
364
+ };
365
+ }
366
+
367
+ // Stage the source — either copy from the local path or fetch from npm.
368
+ let stagedDistDir: string;
369
+ let stagedMetaPath: string | undefined;
370
+ let cleanupNpm: (() => void) | undefined;
371
+
372
+ try {
373
+ if (sourceIsExistingPath) {
374
+ // Local-path branch. Two layouts supported:
375
+ // (a) source is a `dist/` directory directly → use it as is
376
+ // (b) source is a parent containing `dist/` → use parent/dist
377
+ // Detected by presence of `index.html` directly vs `dist/index.html`.
378
+ const sourceAbs = path.resolve(body.source);
379
+ const directIndex = path.join(sourceAbs, "index.html");
380
+ const nestedIndex = path.join(sourceAbs, "dist", "index.html");
381
+ if (existsSync(directIndex)) {
382
+ stagedDistDir = sourceAbs;
383
+ } else if (existsSync(nestedIndex)) {
384
+ stagedDistDir = path.join(sourceAbs, "dist");
385
+ } else {
386
+ return {
387
+ response: Response.json(
388
+ {
389
+ error: "bad_source",
390
+ message: `local path ${sourceAbs} has neither index.html nor dist/index.html`,
391
+ },
392
+ { status: 400 },
393
+ ),
394
+ };
395
+ }
396
+ // Optional meta.json sibling: prefer `<source>/meta.json`, fall back
397
+ // to `<source>/../meta.json` if source pointed at the dist itself.
398
+ const directMeta = path.join(sourceAbs, "meta.json");
399
+ const parentMeta = path.join(path.dirname(sourceAbs), "meta.json");
400
+ stagedMetaPath = existsSync(directMeta)
401
+ ? directMeta
402
+ : existsSync(parentMeta)
403
+ ? parentMeta
404
+ : undefined;
405
+ } else {
406
+ // npm-fetch branch.
407
+ try {
408
+ const fetched = await fetchNpmPackage({
409
+ spec: body.source,
410
+ spawnFn: opts.npmSpawnFn,
411
+ logger: opts.logger,
412
+ });
413
+ stagedDistDir = fetched.distPath;
414
+ stagedMetaPath = fetched.metaJsonPath;
415
+ cleanupNpm = fetched.cleanup;
416
+ } catch (e) {
417
+ if (e instanceof NpmFetchError) {
418
+ const status =
419
+ e.code === "not_found"
420
+ ? 404
421
+ : e.code === "no_dist"
422
+ ? 422
423
+ : e.code === "network_error"
424
+ ? 502
425
+ : 422;
426
+ return {
427
+ response: Response.json(
428
+ {
429
+ error: e.code,
430
+ message: e.message,
431
+ stderr: e.stderr,
432
+ retry_hint: e.retryHint,
433
+ },
434
+ { status },
435
+ ),
436
+ };
437
+ }
438
+ throw e;
439
+ }
440
+ }
441
+
442
+ // Assemble the meta.json the new UI will use. Priority:
443
+ // 1. body overrides
444
+ // 2. stagedMetaPath contents
445
+ // 3. defaults
446
+ let stagedMeta: Record<string, unknown> = {};
447
+ if (stagedMetaPath) {
448
+ try {
449
+ stagedMeta = JSON.parse(readFileSync(stagedMetaPath, "utf8"));
450
+ if (!stagedMeta || typeof stagedMeta !== "object" || Array.isArray(stagedMeta)) {
451
+ stagedMeta = {};
452
+ }
453
+ } catch (e) {
454
+ opts.logger?.warn(
455
+ `[app-admin] couldn't parse staged meta.json at ${stagedMetaPath}: ${(e as Error).message}`,
456
+ );
457
+ }
458
+ }
459
+ const merged: Record<string, unknown> = { ...stagedMeta };
460
+ if (body.name !== undefined) merged.name = body.name;
461
+ if (body.path !== undefined) merged.path = body.path;
462
+ if (body.displayName !== undefined) merged.displayName = body.displayName;
463
+ if (body.tagline !== undefined) merged.tagline = body.tagline;
464
+ if (body.scopes_required !== undefined) merged.scopes_required = body.scopes_required;
465
+ if (body.vault_default !== undefined) merged.vault_default = body.vault_default;
466
+ // Fall back to sensible defaults when neither body nor staged meta has it.
467
+ if (merged.displayName === undefined && typeof merged.name === "string") {
468
+ merged.displayName = merged.name;
469
+ }
470
+
471
+ // Validate the merged meta. Returns `parseMeta`'s typed shape or 400.
472
+ let parsedMeta: ReturnType<typeof parseMeta>;
473
+ try {
474
+ parsedMeta = parseMeta(merged);
475
+ } catch (e) {
476
+ if (e instanceof InvalidMetaError) {
477
+ return {
478
+ response: Response.json(
479
+ { error: "invalid_meta", message: e.message, details: e.details },
480
+ { status: 400 },
481
+ ),
482
+ };
483
+ }
484
+ throw e;
485
+ }
486
+
487
+ // Name + path constraint extra (parseMeta covers regex, but we sanity
488
+ // check that the name fits NAME_PATTERN explicitly here for clarity).
489
+ if (!NAME_PATTERN.test(parsedMeta.name)) {
490
+ return {
491
+ response: Response.json(
492
+ {
493
+ error: "invalid_meta",
494
+ message: `name "${parsedMeta.name}" violates ${NAME_PATTERN.source}`,
495
+ },
496
+ { status: 400 },
497
+ ),
498
+ };
499
+ }
500
+ if (!PATH_PATTERN.test(parsedMeta.path)) {
501
+ return {
502
+ response: Response.json(
503
+ {
504
+ error: "invalid_meta",
505
+ message: `path "${parsedMeta.path}" violates ${PATH_PATTERN.source}`,
506
+ },
507
+ { status: 400 },
508
+ ),
509
+ };
510
+ }
511
+ if (parsedMeta.path === "/app/admin") {
512
+ return {
513
+ response: Response.json(
514
+ { error: "reserved_path", message: "`/app/admin` is reserved for the admin SPA" },
515
+ { status: 409 },
516
+ ),
517
+ };
518
+ }
519
+
520
+ const uisDir = opts.uisDir ?? resolveUisDir();
521
+ const targetDir = path.join(uisDir, parsedMeta.name);
522
+
523
+ if (existsSync(targetDir) && !body.force) {
524
+ return {
525
+ response: Response.json(
526
+ {
527
+ error: "name_exists",
528
+ message: `UI named "${parsedMeta.name}" is already installed at ${targetDir}; pass force=true to replace`,
529
+ },
530
+ { status: 409 },
531
+ ),
532
+ };
533
+ }
534
+
535
+ // Mount-path collision check against the in-memory state (skipped UIs
536
+ // can share a path-by-collision; we want a clean reject).
537
+ const collision = opts.state.registeredUis.find(
538
+ (u) => u.meta.path === parsedMeta.path && u.meta.name !== parsedMeta.name,
539
+ );
540
+ if (collision) {
541
+ return {
542
+ response: Response.json(
543
+ {
544
+ error: "path_taken",
545
+ message: `mount path ${parsedMeta.path} is already claimed by "${collision.meta.name}"`,
546
+ },
547
+ { status: 409 },
548
+ ),
549
+ };
550
+ }
551
+
552
+ // Commit to disk: clear targetDir (force path), copy dist, write meta.
553
+ if (existsSync(targetDir)) {
554
+ rmSync(targetDir, { recursive: true, force: true });
555
+ }
556
+ mkdirSync(targetDir, { recursive: true });
557
+ const targetDist = path.join(targetDir, "dist");
558
+ copyDir(stagedDistDir, targetDist);
559
+ const targetMetaPath = path.join(targetDir, "meta.json");
560
+ writeFileSync(
561
+ targetMetaPath,
562
+ `${JSON.stringify(
563
+ {
564
+ name: parsedMeta.name,
565
+ displayName: parsedMeta.displayName,
566
+ tagline: parsedMeta.tagline,
567
+ path: parsedMeta.path,
568
+ version: parsedMeta.version,
569
+ iconUrl: parsedMeta.iconUrl,
570
+ scopes_required: parsedMeta.scopes_required,
571
+ vault_default: parsedMeta.vault_default,
572
+ pwa: parsedMeta.pwa,
573
+ pwa_service_worker: parsedMeta.pwa_service_worker,
574
+ public: parsedMeta.public,
575
+ // Phase 2.0 — preserve required_schema so the scan in `scanUis()`
576
+ // can rehydrate it from disk + the Phase 2.1 provisioner can
577
+ // re-trigger off it. Without this projection, re-running
578
+ // `parachute-app reload <name>` would lose the declaration.
579
+ required_schema: parsedMeta.required_schema,
580
+ },
581
+ null,
582
+ 2,
583
+ )}\n`,
584
+ );
585
+
586
+ // DCR registration. Best-effort — failures don't unwind the install
587
+ // because the UI is still mountable; the operator can re-register
588
+ // later or click approve in hub admin.
589
+ let oauthRecord: OauthClientRecord | undefined;
590
+ let dcrWarning: string | undefined;
591
+ if (opts.state.config.auto_register_oauth_clients) {
592
+ const operatorToken =
593
+ (opts.operatorTokenOverride
594
+ ? opts.operatorTokenOverride()
595
+ : readOperatorToken({ logger: opts.logger })) ?? undefined;
596
+ const hubUrl = opts.state.config.hub_url;
597
+ const redirectBase = `${hubUrl.replace(/\/$/, "")}${parsedMeta.path}`;
598
+ try {
599
+ const reg = await registerOauthClient({
600
+ hubUrl,
601
+ clientName: parsedMeta.displayName,
602
+ redirectUris: [`${redirectBase}/`, `${redirectBase}/oauth-callback`],
603
+ scopes: parsedMeta.scopes_required,
604
+ operatorToken,
605
+ fetchFn: opts.fetchFn,
606
+ logger: opts.logger,
607
+ });
608
+ oauthRecord = {
609
+ client_id: reg.client_id,
610
+ client_name: reg.client_name ?? parsedMeta.displayName,
611
+ redirect_uris: reg.redirect_uris,
612
+ scope: reg.scope ?? parsedMeta.scopes_required.join(" "),
613
+ status: reg.status,
614
+ registered_at: new Date().toISOString(),
615
+ hub_url: hubUrl,
616
+ };
617
+ writeOauthClientFile(targetDir, oauthRecord);
618
+ } catch (e) {
619
+ if (e instanceof DcrError) {
620
+ dcrWarning = e.message;
621
+ opts.logger?.warn(
622
+ `[app-admin] DCR registration failed for ${parsedMeta.name}: ${e.message}`,
623
+ );
624
+ } else {
625
+ throw e;
626
+ }
627
+ }
628
+ }
629
+
630
+ // Re-scan + swap state.
631
+ const scan = scanUis({ uisDir, logger: opts.logger });
632
+ opts.state.registeredUis = scan.registered;
633
+ opts.state.skippedUis = scan.skipped.map((s: SkippedUi) => ({
634
+ dirName: s.dirName,
635
+ status: s.status,
636
+ reason: s.reason,
637
+ }));
638
+
639
+ // Refresh services.json so hub picks up the new uis-map entry.
640
+ if (!opts.skipSelfRegisterRefresh) {
641
+ try {
642
+ selfRegister({
643
+ boundPort: 0, // ignored — existing entry's port preserves
644
+ installDir: resolveProjectRoot(),
645
+ manifestPath: opts.manifestPath,
646
+ extraFields: { uis: buildUisExtraField(opts.state.registeredUis) },
647
+ logger: opts.logger,
648
+ });
649
+ } catch (e) {
650
+ opts.logger?.warn(`[app-admin] services.json refresh failed: ${(e as Error).message}`);
651
+ }
652
+ }
653
+
654
+ const added = opts.state.registeredUis.find((u) => u.meta.name === parsedMeta.name);
655
+
656
+ // Phase 2.1 — auto-provision required_schema. Best-effort; failures
657
+ // surface as a `provision_schema` warning slot on the response but
658
+ // never unwind the install.
659
+ let provisionSummary: ProvisionSchemaResult | undefined;
660
+ if (added && opts.state.config.auto_provision_required_schema && added.meta.required_schema) {
661
+ try {
662
+ provisionSummary = await provisionSchemaForUi({
663
+ ui: added,
664
+ hubUrl: opts.state.config.hub_url,
665
+ operatorTokenResolver:
666
+ opts.operatorTokenOverride ?? (() => readOperatorToken({ logger: opts.logger })),
667
+ fetchFn: opts.fetchFn,
668
+ logger: opts.logger,
669
+ });
670
+ } catch (e) {
671
+ opts.logger?.warn(
672
+ `[app-admin] schema auto-provision failed for ${parsedMeta.name}: ${(e as Error).message}`,
673
+ );
674
+ }
675
+ }
676
+
677
+ return {
678
+ response: Response.json(
679
+ {
680
+ ok: true,
681
+ ui: added ? serializeUi(added) : null,
682
+ oauth_client_id: oauthRecord?.client_id,
683
+ oauth_status: oauthRecord?.status,
684
+ warning: dcrWarning,
685
+ provision_schema: provisionSummary,
686
+ },
687
+ { status: 201 },
688
+ ),
689
+ ...(added ? { added } : {}),
690
+ ...(oauthRecord ? { oauthRecord } : {}),
691
+ };
692
+ } finally {
693
+ if (cleanupNpm) cleanupNpm();
694
+ }
695
+ }
696
+
697
+ // --- DELETE /app/<name> --------------------------------------------------
698
+
699
+ async function handleDelete(req: Request, name: string, opts: AdminHandlerOpts): Promise<Response> {
700
+ const auth = await runEnforce(req, SCOPE_ADMIN, opts);
701
+ if (auth instanceof Response) return auth;
702
+
703
+ const uisDir = opts.uisDir ?? resolveUisDir();
704
+ const targetDir = path.join(uisDir, name);
705
+
706
+ // We tolerate the case where the UI is in skipped state — operator wants
707
+ // to clean up a broken install. So we look at the directory, not just the
708
+ // active list.
709
+ if (!existsSync(targetDir)) {
710
+ return Response.json({ error: "not_found", message: `no UI at ${targetDir}` }, { status: 404 });
711
+ }
712
+
713
+ // Best-effort revoke OAuth client first.
714
+ const oauth = readOauthClientFile(targetDir);
715
+ const operatorToken =
716
+ (opts.operatorTokenOverride
717
+ ? opts.operatorTokenOverride()
718
+ : readOperatorToken({ logger: opts.logger })) ?? undefined;
719
+ const revoke = await unregisterOauthClient({
720
+ hubUrl: opts.state.config.hub_url,
721
+ clientId: oauth?.client_id,
722
+ uiDir: targetDir,
723
+ operatorToken,
724
+ fetchFn: opts.fetchFn,
725
+ logger: opts.logger,
726
+ });
727
+
728
+ // Remove the directory.
729
+ rmSync(targetDir, { recursive: true, force: true });
730
+
731
+ // Re-scan + swap state.
732
+ const scan = scanUis({ uisDir, logger: opts.logger });
733
+ opts.state.registeredUis = scan.registered;
734
+ opts.state.skippedUis = scan.skipped.map((s) => ({
735
+ dirName: s.dirName,
736
+ status: s.status,
737
+ reason: s.reason,
738
+ }));
739
+
740
+ if (!opts.skipSelfRegisterRefresh) {
741
+ try {
742
+ selfRegister({
743
+ boundPort: 0,
744
+ installDir: resolveProjectRoot(),
745
+ manifestPath: opts.manifestPath,
746
+ extraFields: { uis: buildUisExtraField(opts.state.registeredUis) },
747
+ logger: opts.logger,
748
+ });
749
+ } catch (e) {
750
+ opts.logger?.warn(`[app-admin] services.json refresh failed: ${(e as Error).message}`);
751
+ }
752
+ }
753
+
754
+ return Response.json({
755
+ ok: true,
756
+ removed: name,
757
+ oauth_revoke: revoke,
758
+ });
759
+ }
760
+
761
+ // --- POST /app/<name>/reload --------------------------------------------
762
+
763
+ async function handleReload(req: Request, name: string, opts: AdminHandlerOpts): Promise<Response> {
764
+ const auth = await runEnforce(req, SCOPE_ADMIN, opts);
765
+ if (auth instanceof Response) return auth;
766
+
767
+ const uisDir = opts.uisDir ?? resolveUisDir();
768
+ const targetDir = path.join(uisDir, name);
769
+ if (!existsSync(targetDir)) {
770
+ return Response.json({ error: "not_found", message: `no UI at ${targetDir}` }, { status: 404 });
771
+ }
772
+
773
+ const scan = scanUis({ uisDir, logger: opts.logger });
774
+ opts.state.registeredUis = scan.registered;
775
+ opts.state.skippedUis = scan.skipped.map((s) => ({
776
+ dirName: s.dirName,
777
+ status: s.status,
778
+ reason: s.reason,
779
+ }));
780
+
781
+ if (!opts.skipSelfRegisterRefresh) {
782
+ try {
783
+ selfRegister({
784
+ boundPort: 0,
785
+ installDir: resolveProjectRoot(),
786
+ manifestPath: opts.manifestPath,
787
+ extraFields: { uis: buildUisExtraField(opts.state.registeredUis) },
788
+ logger: opts.logger,
789
+ });
790
+ } catch (e) {
791
+ opts.logger?.warn(`[app-admin] services.json refresh failed: ${(e as Error).message}`);
792
+ }
793
+ }
794
+
795
+ const ui = opts.state.registeredUis.find((u) => u.meta.name === name);
796
+ if (!ui) {
797
+ const skipped = opts.state.skippedUis.find((s) => s.dirName === name);
798
+ return Response.json({
799
+ ok: true,
800
+ ui: null,
801
+ skipped: skipped ?? null,
802
+ message: `UI "${name}" exists on disk but is currently inactive`,
803
+ });
804
+ }
805
+ return Response.json({
806
+ ok: true,
807
+ ui: serializeUi(ui),
808
+ });
809
+ }
810
+
811
+ // --- POST /app/<name>/provision-schema (Phase 2.1) -----------------------
812
+
813
+ /**
814
+ * Manual re-trigger for the auto-provisioning that runs on `add`. Use
815
+ * cases:
816
+ * - Auto-provision failed at add time (vault down, no operator token);
817
+ * operator fixes the underlying issue + re-runs.
818
+ * - Operator changed the meta.json's `required_schema` post-install
819
+ * (added a new tag) and wants the new declarations seeded.
820
+ * - Multi-vault apps where the operator wants to push schema to a
821
+ * specific vault rather than the `vault_default` (override planned;
822
+ * Phase 2.2).
823
+ */
824
+ async function handleProvisionSchema(
825
+ req: Request,
826
+ name: string,
827
+ opts: AdminHandlerOpts,
828
+ ): Promise<Response> {
829
+ const auth = await runEnforce(req, SCOPE_ADMIN, opts);
830
+ if (auth instanceof Response) return auth;
831
+
832
+ const ui = opts.state.registeredUis.find((u) => u.meta.name === name);
833
+ if (!ui) {
834
+ return Response.json({ error: "not_found", message: `no UI named "${name}"` }, { status: 404 });
835
+ }
836
+
837
+ const summary = await provisionSchemaForUi({
838
+ ui,
839
+ hubUrl: opts.state.config.hub_url,
840
+ operatorTokenResolver:
841
+ opts.operatorTokenOverride ?? (() => readOperatorToken({ logger: opts.logger })),
842
+ fetchFn: opts.fetchFn,
843
+ logger: opts.logger,
844
+ });
845
+
846
+ // 200 either way — best-effort. The body carries the per-tag status so
847
+ // the caller can render success/skip/error in the admin SPA.
848
+ return Response.json({
849
+ ok: summary.errors.length === 0,
850
+ name,
851
+ ...summary,
852
+ });
853
+ }
854
+
855
+ /**
856
+ * Assemble the per-UI `uis` map stamped into services.json. Carries the
857
+ * minimum hub needs to render sub-tiles in discovery: display metadata,
858
+ * mount path, scopes, status, and the per-UI OAuth client_id when DCR
859
+ * was successful.
860
+ */
861
+ function buildUisExtraField(uis: ReadonlyArray<RegisteredUi>): Record<string, unknown> {
862
+ const out: Record<string, unknown> = {};
863
+ for (const u of uis) {
864
+ const oauth = readOauthClientFile(u.uiDir);
865
+ out[u.meta.name] = {
866
+ displayName: u.meta.displayName,
867
+ tagline: u.meta.tagline,
868
+ path: u.meta.path,
869
+ iconUrl: u.meta.iconUrl,
870
+ version: u.meta.version,
871
+ scopes_required: u.meta.scopes_required,
872
+ oauthClientId: oauth?.client_id,
873
+ status: "active",
874
+ };
875
+ }
876
+ return out;
877
+ }
878
+
879
+ /** Used by serve() at boot to stamp the same `uis` map on first selfRegister. */
880
+ export function buildUisExtraFieldForBoot(
881
+ uis: ReadonlyArray<RegisteredUi>,
882
+ ): Record<string, unknown> {
883
+ return buildUisExtraField(uis);
884
+ }