@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
|
@@ -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
|
+
}
|