@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,320 @@
1
+ /**
2
+ * npm-fetch shorthand for `POST /app/add`.
3
+ *
4
+ * When the operator passes `source: "@openparachute/notes-ui"` (or any npm
5
+ * specifier matching `(@scope/)?name(@version)?`), apps runs `bun add <spec>`
6
+ * into a staging temp dir, copies the package's `dist/` into the UI's home
7
+ * under `~/.parachute/app/uis/<name>/dist/`, and copies `meta.json` if the
8
+ * package ships one.
9
+ *
10
+ * The flow per design doc section 4:
11
+ * 1. Make a fresh staging dir under `/tmp/parachute-app-staging-<random>`.
12
+ * 2. Initialize a minimal `package.json` so `bun add` has somewhere to
13
+ * write. `bun add` requires a package.json in the cwd.
14
+ * 3. `bun add <spec>` — this fetches + installs into `staging/node_modules/`.
15
+ * 4. Locate the installed package: `staging/node_modules/<pkg-from-spec>`.
16
+ * 5. Validate the package has `dist/index.html` (the bundle).
17
+ * 6. Return path-pointers the caller copies + cleans up.
18
+ *
19
+ * Failure modes:
20
+ * - Spec doesn't match the npm naming pattern → `NpmFetchError` `code: "bad_spec"`.
21
+ * - `bun add` exits non-zero → `code: "fetch_failed"` carrying stderr.
22
+ * - Installed package has no `dist/` → `code: "no_dist"`.
23
+ *
24
+ * The caller (admin-routes `/app/add`) cleans up the staging dir in a
25
+ * `finally` regardless of outcome.
26
+ */
27
+
28
+ import {
29
+ copyFileSync,
30
+ existsSync,
31
+ mkdirSync,
32
+ mkdtempSync,
33
+ readdirSync,
34
+ rmSync,
35
+ statSync,
36
+ writeFileSync,
37
+ } from "node:fs";
38
+ import * as os from "node:os";
39
+ import * as path from "node:path";
40
+
41
+ /** Regex matching valid npm package specifiers, with optional `@version` tail. */
42
+ export const NPM_SPEC_PATTERN = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@(.+))?$/;
43
+
44
+ export class NpmFetchError extends Error {
45
+ override name = "NpmFetchError" as const;
46
+ readonly code:
47
+ | "bad_spec"
48
+ | "fetch_failed"
49
+ | "not_found"
50
+ | "no_dist"
51
+ | "network_error"
52
+ | "staging_failed";
53
+ readonly stderr?: string;
54
+ readonly retryHint?: string;
55
+ constructor(
56
+ message: string,
57
+ code: NpmFetchError["code"],
58
+ extra: { stderr?: string; retryHint?: string } = {},
59
+ ) {
60
+ super(message);
61
+ this.code = code;
62
+ this.stderr = extra.stderr;
63
+ this.retryHint = extra.retryHint;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Parse an npm spec into its package name + optional version.
69
+ *
70
+ * Returns `undefined` if the spec doesn't look like an npm package (caller
71
+ * should fall through to local-path handling). Examples that pass:
72
+ * - `notes-ui`
73
+ * - `@openparachute/notes-ui`
74
+ * - `@openparachute/notes-ui@0.1.2`
75
+ * - `@openparachute/notes-ui@latest`
76
+ *
77
+ * Examples that don't pass (caller treats as local path):
78
+ * - `./foo` (relative path, has `/`)
79
+ * - `/tmp/foo` (absolute path, leading `/`)
80
+ * - `foo/bar/baz` (too many segments — not a scope)
81
+ *
82
+ * The pattern distinguishes spec-vs-path via "starts with `@` or contains no
83
+ * `/`" — a single `/` after a `@scope` is part of an npm name, otherwise a
84
+ * `/` means filesystem path.
85
+ */
86
+ export function parseNpmSpec(spec: string): { pkg: string; version?: string } | undefined {
87
+ if (spec.length === 0) return undefined;
88
+ // Local-path heuristic: starts with `.` or `/`, or contains an internal
89
+ // `/` without a leading `@`.
90
+ if (spec.startsWith(".") || spec.startsWith("/")) return undefined;
91
+ const m = NPM_SPEC_PATTERN.exec(spec);
92
+ if (!m) return undefined;
93
+ return { pkg: m[1]!, version: m[2] };
94
+ }
95
+
96
+ export type NpmFetchOpts = {
97
+ /** The spec. `parseNpmSpec` is called internally; an invalid spec throws `NpmFetchError`. */
98
+ spec: string;
99
+ /** Override the staging-dir parent (tests). Defaults to `os.tmpdir()`. */
100
+ stagingParent?: string;
101
+ /**
102
+ * Override the spawner (tests). Receives the full argv array and returns
103
+ * `{exitCode, stderr}`. Defaults to `Bun.spawn`.
104
+ */
105
+ spawnFn?: NpmSpawnFn;
106
+ /** Logger override; default console. */
107
+ logger?: Pick<Console, "log" | "warn" | "error">;
108
+ };
109
+
110
+ /** Shape `Bun.spawn`-equivalent test mocks need to fulfill. */
111
+ export type NpmSpawnFn = (
112
+ argv: string[],
113
+ cwd: string,
114
+ ) => Promise<{ exitCode: number; stderr: string; stdout: string }>;
115
+
116
+ export type NpmFetchResult = {
117
+ /** The parsed spec. */
118
+ pkg: string;
119
+ version?: string;
120
+ /** Absolute path to the staging dir (caller is responsible for cleanup). */
121
+ stagingDir: string;
122
+ /** Absolute path to `staging/node_modules/<pkg>/`. */
123
+ installedPath: string;
124
+ /** Absolute path to `dist/` within the installed package. */
125
+ distPath: string;
126
+ /** Absolute path to `meta.json` if present (sibling of `dist/`); else undefined. */
127
+ metaJsonPath?: string;
128
+ /** Cleanup the staging dir. Safe to call multiple times. */
129
+ cleanup: () => void;
130
+ };
131
+
132
+ const DEFAULT_SPAWN: NpmSpawnFn = async (argv, cwd) => {
133
+ const proc = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
134
+ const [stdout, stderr] = await Promise.all([
135
+ new Response(proc.stdout).text(),
136
+ new Response(proc.stderr).text(),
137
+ ]);
138
+ const exitCode = await proc.exited;
139
+ return { exitCode, stderr, stdout };
140
+ };
141
+
142
+ /**
143
+ * Fetch + extract an npm package. Returns paths the caller copies into the
144
+ * UI's home; the caller MUST call `result.cleanup()` (typically in a
145
+ * `finally`) regardless of outcome.
146
+ *
147
+ * On error: the staging dir is cleaned up before the throw, so callers don't
148
+ * have to wrap every `try` with a `finally` of their own. (The returned
149
+ * `result.cleanup` is for the success path.)
150
+ */
151
+ export async function fetchNpmPackage(opts: NpmFetchOpts): Promise<NpmFetchResult> {
152
+ const logger = opts.logger ?? console;
153
+ const spawn = opts.spawnFn ?? DEFAULT_SPAWN;
154
+ const stagingParent = opts.stagingParent ?? os.tmpdir();
155
+
156
+ const parsed = parseNpmSpec(opts.spec);
157
+ if (!parsed) {
158
+ throw new NpmFetchError(
159
+ `\"${opts.spec}\" is not a valid npm package specifier (expected name, @scope/name, or @scope/name@version)`,
160
+ "bad_spec",
161
+ );
162
+ }
163
+ const { pkg, version } = parsed;
164
+ const specForBunAdd = version ? `${pkg}@${version}` : pkg;
165
+
166
+ // Staging dir. mkdtempSync gives us a unique name; we own it for the
167
+ // remainder of the operation.
168
+ let stagingDir: string;
169
+ try {
170
+ stagingDir = mkdtempSync(path.join(stagingParent, "parachute-app-staging-"));
171
+ } catch (e) {
172
+ throw new NpmFetchError(
173
+ `failed to create staging directory: ${(e as Error).message}`,
174
+ "staging_failed",
175
+ );
176
+ }
177
+ const cleanup = (): void => {
178
+ try {
179
+ rmSync(stagingDir, { recursive: true, force: true });
180
+ } catch (e) {
181
+ logger.warn(`[app-npm] failed to clean up ${stagingDir}: ${(e as Error).message}`);
182
+ }
183
+ };
184
+
185
+ // Seed a minimal package.json so `bun add` has something to write.
186
+ try {
187
+ writeFileSync(
188
+ path.join(stagingDir, "package.json"),
189
+ `${JSON.stringify({ name: "parachute-app-staging", version: "0.0.0", private: true }, null, 2)}\n`,
190
+ );
191
+ } catch (e) {
192
+ cleanup();
193
+ throw new NpmFetchError(
194
+ `failed to seed staging package.json: ${(e as Error).message}`,
195
+ "staging_failed",
196
+ );
197
+ }
198
+
199
+ // `bun add <spec>` — install into the staging dir.
200
+ // `--ignore-scripts` prevents malicious `postinstall` (et al.) hooks in the
201
+ // fetched package or any of its deps from executing arbitrary code in the
202
+ // daemon's process context. We only need the package's `dist/` output, not
203
+ // any install-time codegen.
204
+ let spawnResult: Awaited<ReturnType<NpmSpawnFn>>;
205
+ try {
206
+ spawnResult = await spawn(["bun", "add", "--ignore-scripts", specForBunAdd], stagingDir);
207
+ } catch (e) {
208
+ cleanup();
209
+ throw new NpmFetchError(`failed to spawn bun: ${(e as Error).message}`, "network_error", {
210
+ retryHint: "ensure `bun` is on PATH",
211
+ });
212
+ }
213
+
214
+ if (spawnResult.exitCode !== 0) {
215
+ const stderr = spawnResult.stderr;
216
+ cleanup();
217
+
218
+ // Distinguish 404 (package doesn't exist) from generic install failures
219
+ // by sniffing stderr — bun's error messages are stable.
220
+ const looks404 =
221
+ /404\b/.test(stderr) ||
222
+ /not found/i.test(stderr) ||
223
+ /no matching version/i.test(stderr) ||
224
+ /no versions available/i.test(stderr);
225
+ const looksNetwork =
226
+ /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|getaddrinfo|EAI_AGAIN/i.test(stderr) ||
227
+ /network error/i.test(stderr) ||
228
+ /failed to connect/i.test(stderr);
229
+ const code: NpmFetchError["code"] = looks404
230
+ ? "not_found"
231
+ : looksNetwork
232
+ ? "network_error"
233
+ : "fetch_failed";
234
+ const retryHint = looksNetwork
235
+ ? "check your network connection or registry config and retry"
236
+ : looks404
237
+ ? `verify the package name + version exist on npm: \`npm view ${specForBunAdd}\``
238
+ : undefined;
239
+ throw new NpmFetchError(
240
+ `\`bun add ${specForBunAdd}\` failed (exit ${spawnResult.exitCode})`,
241
+ code,
242
+ { stderr, retryHint },
243
+ );
244
+ }
245
+
246
+ const installedPath = path.join(stagingDir, "node_modules", pkg);
247
+ if (!existsSync(installedPath)) {
248
+ cleanup();
249
+ throw new NpmFetchError(
250
+ `bun reported success but ${installedPath} doesn't exist — registry shape unexpected`,
251
+ "fetch_failed",
252
+ );
253
+ }
254
+
255
+ // Locate the dist/ inside the installed package. Per the convention
256
+ // (Notes' published `@openparachute/notes-ui` will be the canonical
257
+ // example), the bundle lives at `<pkg>/dist/`.
258
+ const distPath = path.join(installedPath, "dist");
259
+ if (!existsSync(distPath)) {
260
+ cleanup();
261
+ throw new NpmFetchError(
262
+ `package ${specForBunAdd} doesn't contain a dist/ directory — not a parachute-app-shaped UI bundle`,
263
+ "no_dist",
264
+ {
265
+ retryHint:
266
+ "the package should publish a `dist/` directory containing `index.html`; ask the maintainer or build locally + `parachute-app add ./path/to/dist`",
267
+ },
268
+ );
269
+ }
270
+ if (!existsSync(path.join(distPath, "index.html"))) {
271
+ cleanup();
272
+ throw new NpmFetchError(
273
+ `package ${specForBunAdd} has dist/ but no index.html — not a valid SPA bundle`,
274
+ "no_dist",
275
+ );
276
+ }
277
+
278
+ // Sibling meta.json is optional — the caller falls back to body-provided
279
+ // values when missing.
280
+ const metaJsonPath = path.join(installedPath, "meta.json");
281
+ const metaJsonPathResolved = existsSync(metaJsonPath) ? metaJsonPath : undefined;
282
+
283
+ return {
284
+ pkg,
285
+ version,
286
+ stagingDir,
287
+ installedPath,
288
+ distPath,
289
+ metaJsonPath: metaJsonPathResolved,
290
+ cleanup,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Recursive copy of `srcDir` to `destDir`. Replaces destDir if it exists.
296
+ * Mirror of `cp -r src/. dest/` semantics. Used by `POST /app/add` to copy
297
+ * the staged dist into the UI's permanent home.
298
+ */
299
+ export function copyDir(srcDir: string, destDir: string): void {
300
+ if (existsSync(destDir)) {
301
+ rmSync(destDir, { recursive: true, force: true });
302
+ }
303
+ mkdirSync(destDir, { recursive: true });
304
+ copyDirInner(srcDir, destDir);
305
+ }
306
+
307
+ function copyDirInner(src: string, dest: string): void {
308
+ for (const entry of readdirSync(src)) {
309
+ const s = path.join(src, entry);
310
+ const d = path.join(dest, entry);
311
+ const st = statSync(s);
312
+ if (st.isDirectory()) {
313
+ mkdirSync(d, { recursive: true });
314
+ copyDirInner(s, d);
315
+ } else if (st.isFile()) {
316
+ copyFileSync(s, d);
317
+ }
318
+ // Skip symlinks + other entries — keep the bundle to plain files.
319
+ }
320
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Operator bearer sourcing for outbound calls from parachute-app to hub.
3
+ *
4
+ * The DCR registration flow (`POST /oauth/register` on hub) needs an operator
5
+ * bearer in the `Authorization` header. Per hub's `handleRegister`:
6
+ *
7
+ * - No bearer → client lands `pending`; an operator has to click approve.
8
+ * - Operator bearer carrying `hub:admin` scope → client lands `approved`.
9
+ *
10
+ * Apps wants approved (so a `parachute-app add` is one command, not "add
11
+ * then go click approve in hub admin"). The bearer source mirrors what every
12
+ * other operator-side caller uses:
13
+ *
14
+ * 1. `PARACHUTE_HUB_TOKEN` env var (highest priority, transient/CI use)
15
+ * 2. `~/.parachute/operator.token` file (canonical persistent location)
16
+ *
17
+ * Both are operator-controlled. App never *generates* an operator token; it
18
+ * only reads one the operator (or hub install path) wrote. The file mode is
19
+ * verified — group/world-readable files refuse to load so a typo'd `chmod`
20
+ * doesn't leak the token via a backup directory.
21
+ *
22
+ * Missing token → undefined, not an error. The caller decides whether that's
23
+ * fatal (DCR auto-register on `parachute-app add` with `auto_register_oauth_clients=true`)
24
+ * or fine (the `parachute-app list` path needs no outbound calls).
25
+ */
26
+
27
+ import { existsSync, readFileSync, statSync } from "node:fs";
28
+ import * as os from "node:os";
29
+ import * as path from "node:path";
30
+
31
+ /**
32
+ * Resolve the canonical operator-token path. Honors `PARACHUTE_HOME` for
33
+ * sandbox + Render deployments (matches the convention every committed-core
34
+ * module follows).
35
+ */
36
+ export function resolveOperatorTokenPath(
37
+ env: Record<string, string | undefined> = process.env,
38
+ ): string {
39
+ const parachuteHome = env.PARACHUTE_HOME ?? path.join(env.HOME ?? os.homedir(), ".parachute");
40
+ return path.join(parachuteHome, "operator.token");
41
+ }
42
+
43
+ export type ReadOperatorTokenOpts = {
44
+ /** Override env (tests). Defaults to `process.env`. */
45
+ env?: Record<string, string | undefined>;
46
+ /** Override file location (tests). Defaults to `resolveOperatorTokenPath`. */
47
+ tokenPath?: string;
48
+ /** Logger override; default console. */
49
+ logger?: Pick<Console, "log" | "warn" | "error">;
50
+ };
51
+
52
+ /**
53
+ * Read the operator bearer.
54
+ *
55
+ * Priority order:
56
+ * 1. `env.PARACHUTE_HUB_TOKEN` — env-var override (CI + transient flows)
57
+ * 2. The on-disk file at `resolveOperatorTokenPath(env)`
58
+ *
59
+ * Returns `undefined` when nothing's available. Logs a warn if the file
60
+ * exists but is group/world-readable (mode-bit defense — the operator
61
+ * mistyped `chmod` and we refuse to load the token).
62
+ */
63
+ export function readOperatorToken(opts: ReadOperatorTokenOpts = {}): string | undefined {
64
+ const env = opts.env ?? process.env;
65
+ const logger = opts.logger ?? console;
66
+
67
+ const fromEnv = env.PARACHUTE_HUB_TOKEN?.trim();
68
+ if (fromEnv && fromEnv.length > 0) return fromEnv;
69
+
70
+ const tokenPath = opts.tokenPath ?? resolveOperatorTokenPath(env);
71
+ if (!existsSync(tokenPath)) return undefined;
72
+
73
+ try {
74
+ const st = statSync(tokenPath);
75
+ // Posix mode check — refuse to load a token a backup tool might happen
76
+ // to slurp from a group/world-readable file. The expected mode is 0o600
77
+ // (owner-rw only). On Windows the mode bits are meaningless so we skip
78
+ // the check there. `process.platform` is the canonical detector.
79
+ if (process.platform !== "win32") {
80
+ const groupOrWorldReadable = (st.mode & 0o077) !== 0;
81
+ if (groupOrWorldReadable) {
82
+ logger.warn(
83
+ `[app] refusing to load operator token at ${tokenPath}: file is group/world-readable (mode ${(st.mode & 0o777).toString(8)}); chmod 600 to fix`,
84
+ );
85
+ return undefined;
86
+ }
87
+ }
88
+ const body = readFileSync(tokenPath, "utf8").trim();
89
+ if (body.length === 0) return undefined;
90
+ return body;
91
+ } catch (e) {
92
+ logger.warn(`[app] failed to read operator token at ${tokenPath}: ${(e as Error).message}`);
93
+ return undefined;
94
+ }
95
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * required_schema auto-provisioner — Phase 2.1.
3
+ *
4
+ * Per patterns#57 ("Surfaces declare required vault schema"), each
5
+ * hosted UI may declare a `required_schema` envelope in its
6
+ * `meta.json`:
7
+ *
8
+ * {
9
+ * "required_schema": {
10
+ * "tags": [
11
+ * {
12
+ * "name": "capture",
13
+ * "description": "Quick captures",
14
+ * "fields": {
15
+ * "source": { "type": "string", "required": true },
16
+ * "createdAt": { "type": "date" }
17
+ * }
18
+ * }
19
+ * ]
20
+ * }
21
+ * }
22
+ *
23
+ * Phase 2.0 landed the *shape* (validate + surface in admin SPA). This
24
+ * file lands the auto-provisioning logic: when `POST /app/add` succeeds
25
+ * and the UI's meta declares `required_schema.tags`, we call
26
+ * `VaultClient.updateTag` against each declared tag so vault has the
27
+ * schema row the app expects.
28
+ *
29
+ * Idempotent — vault's `PUT /api/tags/:name` upserts (omitted keys
30
+ * preserve, declared keys overwrite). Re-running provisioning against
31
+ * a vault that already has the schema is a no-op at the row level.
32
+ *
33
+ * **Best-effort.** If vault is unreachable, the operator token is
34
+ * absent, or the tag-PUT 4xxs, we log + warn but never unwind the
35
+ * install. The operator can re-trigger via `POST /app/<name>/provision-
36
+ * schema` once the underlying issue is fixed.
37
+ *
38
+ * Which vault?
39
+ * - If `meta.vault_default` is set (single-vault apps), we provision
40
+ * against that vault via `<hub_url>/vault/<vault_default>`.
41
+ * - If unset (multi-vault / vault-agnostic apps), we skip with a
42
+ * human-readable reason. Per design doc Section 5, vault-agnostic
43
+ * UIs declare `vault:*:read`; we don't know which vault to seed,
44
+ * so the operator runs `provision-schema` manually against each
45
+ * vault they want set up.
46
+ */
47
+
48
+ import type { TagUpsertPayload } from "@openparachute/app-client";
49
+ import { VaultClient } from "@openparachute/app-client/vault-client";
50
+
51
+ import type { FetchFn } from "./dcr.ts";
52
+ import type { RegisteredUi } from "./ui-registry.ts";
53
+
54
+ export type ProvisionSchemaOpts = {
55
+ ui: RegisteredUi;
56
+ /** Hub origin (used to construct the per-vault base URL). */
57
+ hubUrl: string;
58
+ /** Resolver for the operator bearer used to authenticate to vault. */
59
+ operatorTokenResolver: () => string | undefined;
60
+ /** Inject fetch (tests). */
61
+ fetchFn?: FetchFn;
62
+ /** Logger override; default console. */
63
+ logger?: Pick<Console, "log" | "warn" | "error">;
64
+ };
65
+
66
+ export type ProvisionSchemaResult = {
67
+ /** Tag names successfully provisioned (PUT returned 2xx). */
68
+ provisioned: string[];
69
+ /** Tags whose PUT failed with the per-tag error message. */
70
+ errors: Array<{ tag: string; error: string }>;
71
+ /**
72
+ * Reason the whole pass was skipped (per-UI), if it was. Examples:
73
+ * - "ui declared no required_schema"
74
+ * - "ui has no vault_default; skip (operator can re-trigger manually)"
75
+ * - "no operator token available"
76
+ */
77
+ skipReason?: string;
78
+ /** Resolved vault URL, when one was used. */
79
+ vaultUrl?: string;
80
+ };
81
+
82
+ /**
83
+ * Provision the tags declared in `ui.meta.required_schema.tags` into
84
+ * the vault implied by `ui.meta.vault_default`.
85
+ *
86
+ * Best-effort: every error path logs + warns and continues to the next
87
+ * tag. Returns a summary the caller surfaces in the admin response +
88
+ * SSE log.
89
+ */
90
+ export async function provisionSchemaForUi(
91
+ opts: ProvisionSchemaOpts,
92
+ ): Promise<ProvisionSchemaResult> {
93
+ const logger = opts.logger ?? console;
94
+ const required = opts.ui.meta.required_schema;
95
+ const tags = required?.tags ?? [];
96
+
97
+ if (!required || tags.length === 0) {
98
+ return {
99
+ provisioned: [],
100
+ errors: [],
101
+ skipReason: "ui declared no required_schema",
102
+ };
103
+ }
104
+
105
+ const vaultName = opts.ui.meta.vault_default;
106
+ if (!vaultName) {
107
+ const reason =
108
+ "ui has no vault_default — apps declaring required_schema must pin a vault, or operator must run provision-schema manually";
109
+ logger.warn(`[app-provision] ${opts.ui.meta.name}: ${reason}`);
110
+ return {
111
+ provisioned: [],
112
+ errors: [],
113
+ skipReason: reason,
114
+ };
115
+ }
116
+
117
+ const operatorToken = opts.operatorTokenResolver();
118
+ if (!operatorToken) {
119
+ const reason =
120
+ "no operator token available (PARACHUTE_HUB_TOKEN unset, ~/.parachute/operator.token missing/unreadable)";
121
+ logger.warn(`[app-provision] ${opts.ui.meta.name}: ${reason}`);
122
+ return {
123
+ provisioned: [],
124
+ errors: [],
125
+ skipReason: reason,
126
+ };
127
+ }
128
+
129
+ const vaultUrl = `${opts.hubUrl.replace(/\/$/, "")}/vault/${encodeURIComponent(vaultName)}`;
130
+ const fetchImpl = opts.fetchFn ?? (fetch as typeof fetch);
131
+ const client = new VaultClient({
132
+ vaultUrl,
133
+ accessToken: operatorToken,
134
+ // Cast fits — server-side FetchFn matches DOM `typeof fetch` at the
135
+ // call-site shape VaultClient needs (URL string + RequestInit).
136
+ fetchImpl: fetchImpl as unknown as typeof fetch,
137
+ });
138
+
139
+ const provisioned: string[] = [];
140
+ const errors: Array<{ tag: string; error: string }> = [];
141
+
142
+ for (const tag of tags) {
143
+ const payload: TagUpsertPayload = {};
144
+ if (tag.description !== undefined) payload.description = tag.description;
145
+ if (tag.fields !== undefined) {
146
+ // Translate the meta.json shape (`{type, required?, description?}`)
147
+ // into vault's `TagFieldSchema` (`{type, description?, enum?}`).
148
+ // The `required` flag from meta.json doesn't have a direct vault
149
+ // equivalent today — vault enforces required-ness at the
150
+ // applySchemaDefaults layer (see vault/routes.ts:applySchemaDefaults).
151
+ // We pass `type` + `description` through; `required` is preserved
152
+ // in the meta.json view + the admin SPA surface, but isn't
153
+ // forwarded as a wire-level flag because vault doesn't store one.
154
+ const fields: Record<string, { type: string; description?: string }> = {};
155
+ for (const [fieldName, decl] of Object.entries(tag.fields)) {
156
+ const f: { type: string; description?: string } = { type: decl.type };
157
+ if (decl.description !== undefined) f.description = decl.description;
158
+ fields[fieldName] = f;
159
+ }
160
+ payload.fields = fields;
161
+ }
162
+ try {
163
+ await client.updateTag(tag.name, payload);
164
+ provisioned.push(tag.name);
165
+ logger.log(`[app-provision] ${opts.ui.meta.name}: provisioned tag "${tag.name}"`);
166
+ } catch (e) {
167
+ const msg = (e as Error).message ?? String(e);
168
+ errors.push({ tag: tag.name, error: msg });
169
+ logger.warn(
170
+ `[app-provision] ${opts.ui.meta.name}: failed to provision tag "${tag.name}": ${msg}`,
171
+ );
172
+ }
173
+ }
174
+
175
+ return {
176
+ provisioned,
177
+ errors,
178
+ vaultUrl,
179
+ };
180
+ }