@openparachute/app 0.2.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,715 @@
1
+ /**
2
+ * `meta.json` schema definition + validator for parachute-app's hosted UIs.
3
+ *
4
+ * Each hosted UI ships a `meta.json` (Draft-07-shaped, see design doc section
5
+ * 5). This file defines the in-memory `UiMeta` type and a hand-rolled
6
+ * validator. We use a hand-rolled checker rather than pulling in ajv because:
7
+ *
8
+ * 1. The schema is small and stable (8 fields, half optional).
9
+ * 2. Hand-rolling lets validation errors point at human-meaningful paths
10
+ * (`"meta.json: path must match ^/app/[a-z0-9-]+$"`) without ajv's
11
+ * JSON-pointer noise.
12
+ * 3. One fewer transitive dep keeps app's startup footprint lean.
13
+ *
14
+ * If schema complexity grows past ~15 fields we'll revisit. For now the
15
+ * tradeoff favors the hand-roll.
16
+ *
17
+ * Canonical reference: design doc section 5 (`meta.json` schema). When
18
+ * fields change there, update this file's shape + `metaSchemaJson()` together.
19
+ */
20
+
21
+ /** Allowed pattern for a UI's `name` (directory + URL-safe key). */
22
+ export const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
23
+
24
+ /** Allowed pattern for a UI's mount `path` (always under `/app/`). */
25
+ export const PATH_PATTERN = /^\/app\/[a-z0-9-]+$/;
26
+
27
+ /**
28
+ * Default scopes applied when meta.json omits `scopes_required`.
29
+ *
30
+ * Per design doc section 5: vault-agnostic UIs (those that don't pin a
31
+ * specific vault via `vault_default`) default to the wildcard form
32
+ * `vault:*:read` — the `*` is the `<vault-name>` segment, expressing
33
+ * "read access to whichever vault the session is bound to." The bare
34
+ * `vault:read` form is not a valid scope shape (missing the name segment).
35
+ */
36
+ export const DEFAULT_SCOPES_REQUIRED: readonly string[] = ["vault:*:read"];
37
+
38
+ /**
39
+ * Allowed field types for `required_schema.tags[].fields`. Mirrors what
40
+ * vault's tag-identity schema accepts on the `fields` column — `string`,
41
+ * `number`, `boolean`, `date`. New types should be added here AND in
42
+ * vault's schema before being declared by an app, otherwise the
43
+ * Phase 2.1+ auto-provisioner will reject the declaration.
44
+ */
45
+ export const REQUIRED_SCHEMA_FIELD_TYPES = ["string", "number", "boolean", "date"] as const;
46
+ export type RequiredSchemaFieldType = (typeof REQUIRED_SCHEMA_FIELD_TYPES)[number];
47
+
48
+ /**
49
+ * Field declaration within a tag-role schema. Mirrors vault's tag-
50
+ * identity field shape closely enough that the Phase 2.1+ auto-
51
+ * provisioner can map declarations to upsert calls without translation.
52
+ */
53
+ export type TagSchemaFieldDeclaration = {
54
+ type: RequiredSchemaFieldType;
55
+ required?: boolean;
56
+ description?: string;
57
+ };
58
+
59
+ /**
60
+ * Tag-role schema declaration — what an app says it needs vault to have
61
+ * defined to function. Phase 2.0 (this revision): validate the shape
62
+ * only. Phase 2.1+ will auto-provision missing tag definitions in vault
63
+ * via `VaultClient.updateTag` at install time; that wiring is captured
64
+ * separately and depends on this declaration landing first.
65
+ */
66
+ export type TagSchemaDeclaration = {
67
+ /** Tag name (e.g. `"capture"`). */
68
+ name: string;
69
+ /** Operator-facing description; surfaced in the admin SPA. */
70
+ description?: string;
71
+ /** Per-field declarations. Keys are field names; values are type + optionality. */
72
+ fields?: Record<string, TagSchemaFieldDeclaration>;
73
+ /**
74
+ * Optional parent tag names for nested tag hierarchies. Each entry must
75
+ * itself be declared elsewhere in the same `required_schema.tags` array
76
+ * (or be a tag the vault already has). Phase 2.1+ auto-provisioner uses
77
+ * this to mint the parent-child relationship in vault via vault's
78
+ * `parent_names` column (see parachute-vault core/src/tag-hierarchy.ts).
79
+ *
80
+ * Example: `{ name: "capture/text", parent_names: ["capture"] }` —
81
+ * a query for `tag: "capture"` then auto-expands to notes tagged
82
+ * `capture/text` or `capture/voice`. Phase 2.0 validates the shape
83
+ * only; cross-reference validation ("does the parent exist?") is the
84
+ * auto-provisioner's job.
85
+ */
86
+ parent_names?: string[];
87
+ };
88
+
89
+ /**
90
+ * Top-level `required_schema` shape. Apps declare schema requirements
91
+ * inside this envelope so future extensions (links, indexes, etc.)
92
+ * don't pollute the top-level meta.json namespace.
93
+ *
94
+ * Patterns#57 — "Surfaces declare required vault schema." Each app
95
+ * should be able to declare its needed tag schemas in meta.json so
96
+ * they auto-provision. This Phase 2.0 lands the SCHEMA declaration
97
+ * (validate + surface); auto-provisioning is Phase 2.1+.
98
+ */
99
+ export type RequiredSchemaDeclaration = {
100
+ tags?: TagSchemaDeclaration[];
101
+ };
102
+
103
+ /**
104
+ * Validated, in-memory shape of a UI's meta.json. Optional fields are filled
105
+ * with their schema defaults at parse time, so consumers can read them
106
+ * unconditionally.
107
+ */
108
+ export type UiMeta = {
109
+ /** Stable identifier. Pattern: `^[a-z][a-z0-9-]*$`. */
110
+ name: string;
111
+ /** Human label rendered on hub discovery. */
112
+ displayName: string;
113
+ /** One-line description rendered under displayName. */
114
+ tagline?: string;
115
+ /** Mount path under hub origin. Pattern: `^/app/[a-z0-9-]+$`. */
116
+ path: string;
117
+ /** Free-form version string (rendered for diagnostics). */
118
+ version?: string;
119
+ /** Path to icon, relative to the UI bundle (e.g. `"icon.svg"`). */
120
+ iconUrl?: string;
121
+ /** OAuth scopes the UI declares as required. Defaults to `["vault:*:read"]`. */
122
+ scopes_required: string[];
123
+ /** Optional single-vault binding hint for vault-specific UIs. */
124
+ vault_default?: string;
125
+ /** Whether app should serve a service worker for this UI. Defaults to `false`. */
126
+ pwa: boolean;
127
+ /** Path within `dist/` to the SW file (e.g. `"sw.js"`). Required when `pwa: true`. */
128
+ pwa_service_worker?: string;
129
+ /** If `true`, hub does NOT enforce a session gate at `/app/<name>/*`. Defaults to `false`. */
130
+ public: boolean;
131
+ /**
132
+ * Optional declaration of vault schema this app needs to function.
133
+ * Phase 2.0 lands the shape (validate + surface in admin SPA); the
134
+ * auto-provisioning that would create missing tag-identity rows in
135
+ * vault at install time is Phase 2.1+. See `RequiredSchemaDeclaration`.
136
+ * Per patterns#57 ("Surfaces declare required vault schema").
137
+ */
138
+ required_schema?: RequiredSchemaDeclaration;
139
+ /**
140
+ * Phase 3.0 — dev-mode file watcher source dir, expressed as a path
141
+ * relative to the UI's root directory (`<uis>/<dirName>/`). The watcher
142
+ * (recursive) fires `onChange` on any descendant change. Default when
143
+ * absent: the UI's root dir minus `dist/` and `node_modules/` (handled
144
+ * by the watcher's filter). Set this to e.g. `"../gitcoin-brain-ui/src"`
145
+ * when the UI's source tree lives outside the installed bundle and the
146
+ * operator is iterating from a checkout.
147
+ */
148
+ dev_watch_dir?: string;
149
+ /**
150
+ * Phase 3.0 — shell command to run on file change before broadcasting a
151
+ * reload. Spawned via `sh -c <cmd>` with the UI's root dir as cwd. Empty
152
+ * / absent → no build step; the watcher emits a reload directly. The
153
+ * command should produce a fresh `dist/` (or whatever the bundle's
154
+ * served files are) — app rebroadcasts on success and skips reload on
155
+ * non-zero exit. Build output is captured to logs.
156
+ */
157
+ dev_build_cmd?: string;
158
+ /**
159
+ * Phase 3.0 — debounce window (ms) for batched file-change events.
160
+ * Default 250ms. Build tools that touch many files in quick succession
161
+ * (esbuild, Vite, tsc --watch) produce one reload per quiet-window
162
+ * instead of one per file. Lower bound enforced at 50ms — anything
163
+ * smaller risks reload-thrashing during a multi-file build.
164
+ */
165
+ dev_debounce_ms?: number;
166
+ };
167
+
168
+ /**
169
+ * Thrown when a `meta.json` doesn't parse, doesn't typecheck, or fails one
170
+ * of the schema constraints. `details` is a flat list of field-level errors
171
+ * the caller can surface to the operator (CLI + admin SPA).
172
+ */
173
+ export class InvalidMetaError extends Error {
174
+ override name = "InvalidMetaError" as const;
175
+ readonly details: ReadonlyArray<{ path: string; message: string }>;
176
+ constructor(message: string, details: Array<{ path: string; message: string }>) {
177
+ super(`${message}: ${details.map((d) => `${d.path}: ${d.message}`).join("; ")}`);
178
+ this.details = details;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Parse + validate a raw JSON object as `UiMeta`. Throws `InvalidMetaError`
184
+ * with a flat list of field-level reasons on any structural problem.
185
+ *
186
+ * Defaults filled at parse time:
187
+ * - `scopes_required` → `["vault:*:read"]` when absent
188
+ * - `pwa` → `false` when absent
189
+ * - `public` → `false` when absent
190
+ */
191
+ export function parseMeta(raw: unknown): UiMeta {
192
+ const errors: Array<{ path: string; message: string }> = [];
193
+
194
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
195
+ throw new InvalidMetaError("meta.json", [{ path: "", message: "must be a JSON object" }]);
196
+ }
197
+ const o = raw as Record<string, unknown>;
198
+
199
+ // name — required, pattern-constrained.
200
+ let name = "";
201
+ if (typeof o.name !== "string" || o.name.length === 0) {
202
+ errors.push({ path: "name", message: "is required (string)" });
203
+ } else if (!NAME_PATTERN.test(o.name)) {
204
+ errors.push({ path: "name", message: `must match ${NAME_PATTERN.source}` });
205
+ } else {
206
+ name = o.name;
207
+ }
208
+
209
+ // displayName — required, non-empty string.
210
+ let displayName = "";
211
+ if (typeof o.displayName !== "string" || o.displayName.length === 0) {
212
+ errors.push({ path: "displayName", message: "is required (non-empty string)" });
213
+ } else {
214
+ displayName = o.displayName;
215
+ }
216
+
217
+ // tagline — optional, string when present.
218
+ let tagline: string | undefined;
219
+ if (o.tagline !== undefined) {
220
+ if (typeof o.tagline !== "string") {
221
+ errors.push({ path: "tagline", message: "must be a string" });
222
+ } else {
223
+ tagline = o.tagline;
224
+ }
225
+ }
226
+
227
+ // path — required, pattern-constrained.
228
+ let pathField = "";
229
+ if (typeof o.path !== "string" || o.path.length === 0) {
230
+ errors.push({ path: "path", message: "is required (string)" });
231
+ } else if (!PATH_PATTERN.test(o.path)) {
232
+ errors.push({ path: "path", message: `must match ${PATH_PATTERN.source}` });
233
+ } else {
234
+ pathField = o.path;
235
+ }
236
+
237
+ // version — optional, string when present.
238
+ let version: string | undefined;
239
+ if (o.version !== undefined) {
240
+ if (typeof o.version !== "string") {
241
+ errors.push({ path: "version", message: "must be a string" });
242
+ } else {
243
+ version = o.version;
244
+ }
245
+ }
246
+
247
+ // iconUrl — optional, string when present.
248
+ let iconUrl: string | undefined;
249
+ if (o.iconUrl !== undefined) {
250
+ if (typeof o.iconUrl !== "string") {
251
+ errors.push({ path: "iconUrl", message: "must be a string" });
252
+ } else {
253
+ iconUrl = o.iconUrl;
254
+ }
255
+ }
256
+
257
+ // scopes_required — optional array of strings; default to DEFAULT_SCOPES_REQUIRED.
258
+ let scopes_required: string[] = [...DEFAULT_SCOPES_REQUIRED];
259
+ if (o.scopes_required !== undefined) {
260
+ if (!Array.isArray(o.scopes_required)) {
261
+ errors.push({ path: "scopes_required", message: "must be an array of strings" });
262
+ } else {
263
+ const items: string[] = [];
264
+ let bad = false;
265
+ for (let i = 0; i < o.scopes_required.length; i++) {
266
+ const v = o.scopes_required[i];
267
+ if (typeof v !== "string" || v.length === 0) {
268
+ errors.push({
269
+ path: `scopes_required[${i}]`,
270
+ message: "must be a non-empty string",
271
+ });
272
+ bad = true;
273
+ break;
274
+ }
275
+ items.push(v);
276
+ }
277
+ if (!bad) scopes_required = items;
278
+ }
279
+ }
280
+
281
+ // vault_default — optional, string when present.
282
+ let vault_default: string | undefined;
283
+ if (o.vault_default !== undefined) {
284
+ if (typeof o.vault_default !== "string" || o.vault_default.length === 0) {
285
+ errors.push({ path: "vault_default", message: "must be a non-empty string" });
286
+ } else {
287
+ vault_default = o.vault_default;
288
+ }
289
+ }
290
+
291
+ // pwa — optional boolean; default false.
292
+ let pwa = false;
293
+ if (o.pwa !== undefined) {
294
+ if (typeof o.pwa !== "boolean") {
295
+ errors.push({ path: "pwa", message: "must be a boolean" });
296
+ } else {
297
+ pwa = o.pwa;
298
+ }
299
+ }
300
+
301
+ // pwa_service_worker — optional string; required when pwa===true.
302
+ let pwa_service_worker: string | undefined;
303
+ if (o.pwa_service_worker !== undefined) {
304
+ if (typeof o.pwa_service_worker !== "string" || o.pwa_service_worker.length === 0) {
305
+ errors.push({
306
+ path: "pwa_service_worker",
307
+ message: "must be a non-empty string",
308
+ });
309
+ } else if (o.pwa_service_worker.startsWith("/")) {
310
+ // Path within dist/ — leading-slash would imply "absolute under mount"
311
+ // and trip up the resolver. Force operator-friendly relative form.
312
+ errors.push({
313
+ path: "pwa_service_worker",
314
+ message: "must be a relative path within dist/ (no leading slash)",
315
+ });
316
+ } else {
317
+ pwa_service_worker = o.pwa_service_worker;
318
+ }
319
+ }
320
+ if (pwa && !pwa_service_worker) {
321
+ errors.push({
322
+ path: "pwa_service_worker",
323
+ message: "is required when `pwa` is true",
324
+ });
325
+ }
326
+
327
+ // public — optional boolean; default false.
328
+ let publicField = false;
329
+ if (o.public !== undefined) {
330
+ if (typeof o.public !== "boolean") {
331
+ errors.push({ path: "public", message: "must be a boolean" });
332
+ } else {
333
+ publicField = o.public;
334
+ }
335
+ }
336
+
337
+ // required_schema — optional object; patterns#57 (Phase 2.0 lands shape,
338
+ // Phase 2.1+ auto-provisions).
339
+ const required_schema = parseRequiredSchema(o.required_schema, errors);
340
+
341
+ // dev_watch_dir — optional string; Phase 3.0. Must be relative to the
342
+ // UI's root directory — absolute paths are rejected as a footgun guard
343
+ // (operators who genuinely want to watch an absolute path should use a
344
+ // symlink or different tooling). Mirrors `pwa_service_worker`'s
345
+ // leading-slash rejection.
346
+ let dev_watch_dir: string | undefined;
347
+ if (o.dev_watch_dir !== undefined) {
348
+ if (typeof o.dev_watch_dir !== "string" || o.dev_watch_dir.length === 0) {
349
+ errors.push({
350
+ path: "dev_watch_dir",
351
+ message: "must be a non-empty string (path relative to UI root)",
352
+ });
353
+ } else if (o.dev_watch_dir.startsWith("/")) {
354
+ errors.push({
355
+ path: "dev_watch_dir",
356
+ message: `must be relative to the UI's root directory (got absolute path: "${o.dev_watch_dir}")`,
357
+ });
358
+ } else {
359
+ dev_watch_dir = o.dev_watch_dir;
360
+ }
361
+ }
362
+
363
+ // dev_build_cmd — optional string; Phase 3.0. Spawned via `sh -c`.
364
+ let dev_build_cmd: string | undefined;
365
+ if (o.dev_build_cmd !== undefined) {
366
+ if (typeof o.dev_build_cmd !== "string" || o.dev_build_cmd.length === 0) {
367
+ errors.push({
368
+ path: "dev_build_cmd",
369
+ message: "must be a non-empty string (shell command to run on file change)",
370
+ });
371
+ } else {
372
+ dev_build_cmd = o.dev_build_cmd;
373
+ }
374
+ }
375
+
376
+ // dev_debounce_ms — optional integer ≥ 50; Phase 3.0.
377
+ let dev_debounce_ms: number | undefined;
378
+ if (o.dev_debounce_ms !== undefined) {
379
+ if (
380
+ typeof o.dev_debounce_ms !== "number" ||
381
+ !Number.isFinite(o.dev_debounce_ms) ||
382
+ !Number.isInteger(o.dev_debounce_ms) ||
383
+ o.dev_debounce_ms < 50
384
+ ) {
385
+ errors.push({
386
+ path: "dev_debounce_ms",
387
+ message: "must be an integer ≥ 50 (milliseconds)",
388
+ });
389
+ } else {
390
+ dev_debounce_ms = o.dev_debounce_ms;
391
+ }
392
+ }
393
+
394
+ if (errors.length > 0) {
395
+ throw new InvalidMetaError("meta.json", errors);
396
+ }
397
+
398
+ return {
399
+ name,
400
+ displayName,
401
+ tagline,
402
+ path: pathField,
403
+ version,
404
+ iconUrl,
405
+ scopes_required,
406
+ vault_default,
407
+ pwa,
408
+ pwa_service_worker,
409
+ public: publicField,
410
+ ...(required_schema ? { required_schema } : {}),
411
+ ...(dev_watch_dir !== undefined ? { dev_watch_dir } : {}),
412
+ ...(dev_build_cmd !== undefined ? { dev_build_cmd } : {}),
413
+ ...(dev_debounce_ms !== undefined ? { dev_debounce_ms } : {}),
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Parse + validate the `required_schema` envelope. Returns `undefined`
419
+ * when the key is absent (it's optional); appends to the shared `errors`
420
+ * list and returns `undefined` on any shape problem (rejecting the
421
+ * meta.json as a whole when the top-level `parseMeta` loop sees errors).
422
+ *
423
+ * Validation rules:
424
+ * - top-level must be an object (not array, not null)
425
+ * - `tags` must be an array if present
426
+ * - each tag entry must be an object with a required `name` string
427
+ * - `description` optional; must be string when present
428
+ * - `fields` optional; must be an object whose values are
429
+ * `{ type: <one of REQUIRED_SCHEMA_FIELD_TYPES>, required?: bool, description?: string }`
430
+ */
431
+ function parseRequiredSchema(
432
+ raw: unknown,
433
+ errors: Array<{ path: string; message: string }>,
434
+ ): RequiredSchemaDeclaration | undefined {
435
+ if (raw === undefined) return undefined;
436
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
437
+ errors.push({ path: "required_schema", message: "must be an object" });
438
+ return undefined;
439
+ }
440
+ const o = raw as Record<string, unknown>;
441
+ const out: RequiredSchemaDeclaration = {};
442
+
443
+ if (o.tags !== undefined) {
444
+ if (!Array.isArray(o.tags)) {
445
+ errors.push({ path: "required_schema.tags", message: "must be an array" });
446
+ } else {
447
+ const tags: TagSchemaDeclaration[] = [];
448
+ let bad = false;
449
+ for (let i = 0; i < o.tags.length; i++) {
450
+ const entry = o.tags[i];
451
+ const pathPrefix = `required_schema.tags[${i}]`;
452
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
453
+ errors.push({ path: pathPrefix, message: "must be an object" });
454
+ bad = true;
455
+ continue;
456
+ }
457
+ const t = entry as Record<string, unknown>;
458
+
459
+ if (typeof t.name !== "string" || t.name.length === 0) {
460
+ errors.push({ path: `${pathPrefix}.name`, message: "is required (non-empty string)" });
461
+ bad = true;
462
+ continue;
463
+ }
464
+ const tag: TagSchemaDeclaration = { name: t.name };
465
+
466
+ if (t.description !== undefined) {
467
+ if (typeof t.description !== "string") {
468
+ errors.push({ path: `${pathPrefix}.description`, message: "must be a string" });
469
+ bad = true;
470
+ continue;
471
+ }
472
+ tag.description = t.description;
473
+ }
474
+
475
+ if (t.parent_names !== undefined) {
476
+ if (!Array.isArray(t.parent_names)) {
477
+ errors.push({
478
+ path: `${pathPrefix}.parent_names`,
479
+ message: "must be an array of non-empty strings",
480
+ });
481
+ bad = true;
482
+ continue;
483
+ }
484
+ const parents: string[] = [];
485
+ let parentsBad = false;
486
+ for (let j = 0; j < t.parent_names.length; j++) {
487
+ const p = t.parent_names[j];
488
+ if (typeof p !== "string" || p.length === 0) {
489
+ errors.push({
490
+ path: `${pathPrefix}.parent_names[${j}]`,
491
+ message: "must be a non-empty string",
492
+ });
493
+ parentsBad = true;
494
+ break;
495
+ }
496
+ parents.push(p);
497
+ }
498
+ if (parentsBad) {
499
+ bad = true;
500
+ continue;
501
+ }
502
+ // Preserve `[]` distinct from `undefined` — explicit empty
503
+ // is a deliberate operator signal (no parents) and we let
504
+ // the admin SPA / auto-provisioner distinguish the two.
505
+ tag.parent_names = parents;
506
+ }
507
+
508
+ if (t.fields !== undefined) {
509
+ if (!t.fields || typeof t.fields !== "object" || Array.isArray(t.fields)) {
510
+ errors.push({ path: `${pathPrefix}.fields`, message: "must be an object" });
511
+ bad = true;
512
+ continue;
513
+ }
514
+ const fields: Record<string, TagSchemaFieldDeclaration> = {};
515
+ let fieldsBad = false;
516
+ for (const [fieldName, fieldRaw] of Object.entries(t.fields as Record<string, unknown>)) {
517
+ const fieldPath = `${pathPrefix}.fields.${fieldName}`;
518
+ if (!fieldRaw || typeof fieldRaw !== "object" || Array.isArray(fieldRaw)) {
519
+ errors.push({ path: fieldPath, message: "must be an object" });
520
+ fieldsBad = true;
521
+ continue;
522
+ }
523
+ const f = fieldRaw as Record<string, unknown>;
524
+ const t2 = f.type;
525
+ if (
526
+ typeof t2 !== "string" ||
527
+ !(REQUIRED_SCHEMA_FIELD_TYPES as readonly string[]).includes(t2)
528
+ ) {
529
+ errors.push({
530
+ path: `${fieldPath}.type`,
531
+ message: `must be one of ${REQUIRED_SCHEMA_FIELD_TYPES.join(", ")}`,
532
+ });
533
+ fieldsBad = true;
534
+ continue;
535
+ }
536
+ const decl: TagSchemaFieldDeclaration = { type: t2 as RequiredSchemaFieldType };
537
+ if (f.required !== undefined) {
538
+ if (typeof f.required !== "boolean") {
539
+ errors.push({ path: `${fieldPath}.required`, message: "must be a boolean" });
540
+ fieldsBad = true;
541
+ continue;
542
+ }
543
+ decl.required = f.required;
544
+ }
545
+ if (f.description !== undefined) {
546
+ if (typeof f.description !== "string") {
547
+ errors.push({ path: `${fieldPath}.description`, message: "must be a string" });
548
+ fieldsBad = true;
549
+ continue;
550
+ }
551
+ decl.description = f.description;
552
+ }
553
+ fields[fieldName] = decl;
554
+ }
555
+ if (!fieldsBad) tag.fields = fields;
556
+ else {
557
+ bad = true;
558
+ continue;
559
+ }
560
+ }
561
+
562
+ tags.push(tag);
563
+ }
564
+ if (!bad) out.tags = tags;
565
+ }
566
+ }
567
+
568
+ // Even with empty/no `tags`, an explicit empty `required_schema: {}` is
569
+ // a deliberate operator declaration ("no schema needed"). Surface it
570
+ // unchanged so the admin SPA can distinguish "didn't declare" from
571
+ // "declared empty."
572
+ return out;
573
+ }
574
+
575
+ /**
576
+ * Public JSON-Schema description, matching the in-memory `UiMeta` shape.
577
+ * Exposed so the admin SPA + docs can render a single source of truth.
578
+ * Kept in sync with `parseMeta()` by hand — there's only one schema to
579
+ * keep aligned, and the unit tests below assert both surfaces agree.
580
+ */
581
+ export function metaSchemaJson(): Record<string, unknown> {
582
+ return {
583
+ $schema: "http://json-schema.org/draft-07/schema#",
584
+ $id: "https://parachute.computer/schemas/app-ui-meta.json",
585
+ title: "parachute-app UI meta.json",
586
+ type: "object",
587
+ additionalProperties: false,
588
+ required: ["name", "displayName", "path"],
589
+ properties: {
590
+ name: {
591
+ type: "string",
592
+ pattern: NAME_PATTERN.source,
593
+ description: "Stable identifier. Becomes the uis/<name>/ directory and OAuth client name.",
594
+ },
595
+ displayName: {
596
+ type: "string",
597
+ description: "Human label rendered on hub discovery.",
598
+ },
599
+ tagline: {
600
+ type: "string",
601
+ description: "One-line description rendered under displayName.",
602
+ },
603
+ path: {
604
+ type: "string",
605
+ pattern: PATH_PATTERN.source,
606
+ description: "Mount path under hub origin, always under /app/ (e.g. '/app/gitcoin-brain').",
607
+ },
608
+ version: {
609
+ type: "string",
610
+ description: "Bundle version. Free-form; rendered for diagnostics.",
611
+ },
612
+ iconUrl: {
613
+ type: "string",
614
+ description: "Path to icon, relative to the UI bundle (e.g. 'icon.svg').",
615
+ },
616
+ scopes_required: {
617
+ type: "array",
618
+ items: { type: "string" },
619
+ default: [...DEFAULT_SCOPES_REQUIRED],
620
+ description: "OAuth scopes the UI declares as required.",
621
+ },
622
+ vault_default: {
623
+ type: "string",
624
+ description: "Optional single-vault binding hint for vault-specific UIs.",
625
+ },
626
+ pwa: {
627
+ type: "boolean",
628
+ default: false,
629
+ description: "Opt into PWA mode — app serves the SW file with no-cache.",
630
+ },
631
+ pwa_service_worker: {
632
+ type: "string",
633
+ description: "Path within dist/ to the SW file (e.g. 'sw.js'). Required when pwa: true.",
634
+ },
635
+ public: {
636
+ type: "boolean",
637
+ default: false,
638
+ description: "If true, hub does not enforce a session gate at /app/<name>/*.",
639
+ },
640
+ dev_watch_dir: {
641
+ type: "string",
642
+ description:
643
+ "Phase 3.0 — directory (relative to UI root) the dev-mode file watcher monitors. Default: the UI's root directory minus dist/ and node_modules/.",
644
+ },
645
+ dev_build_cmd: {
646
+ type: "string",
647
+ description:
648
+ "Phase 3.0 — shell command run on file change in dev mode (via `sh -c`); cwd is the UI's root directory. Absent → no build step; the watcher emits a reload directly.",
649
+ },
650
+ dev_debounce_ms: {
651
+ type: "integer",
652
+ minimum: 50,
653
+ description:
654
+ "Phase 3.0 — debounce window (ms) for batched file-change events. Default 250.",
655
+ },
656
+ required_schema: {
657
+ type: "object",
658
+ additionalProperties: false,
659
+ description:
660
+ "Optional declaration of vault schema this app needs to function. Phase 2.0 validates shape; Phase 2.1+ auto-provisions missing tag-identity rows. Per patterns#57.",
661
+ properties: {
662
+ tags: {
663
+ type: "array",
664
+ description: "Tag-role declarations the app expects vault to have.",
665
+ items: {
666
+ type: "object",
667
+ additionalProperties: false,
668
+ required: ["name"],
669
+ properties: {
670
+ name: {
671
+ type: "string",
672
+ description: "Tag name (e.g. 'capture').",
673
+ },
674
+ description: {
675
+ type: "string",
676
+ description: "Operator-facing description; surfaced in the admin SPA.",
677
+ },
678
+ parent_names: {
679
+ type: "array",
680
+ items: { type: "string" },
681
+ description:
682
+ "Optional parent tag names for nested tag hierarchies (e.g. ['capture'] for tag 'capture/text'). Phase 2.1+ auto-provisioner uses this to mint the parent-child relationship in vault.",
683
+ },
684
+ fields: {
685
+ type: "object",
686
+ description: "Per-field declarations keyed by field name.",
687
+ additionalProperties: {
688
+ type: "object",
689
+ additionalProperties: false,
690
+ required: ["type"],
691
+ properties: {
692
+ type: {
693
+ type: "string",
694
+ enum: [...REQUIRED_SCHEMA_FIELD_TYPES],
695
+ description: "Field type (matches vault's tag-identity field shape).",
696
+ },
697
+ required: {
698
+ type: "boolean",
699
+ description: "Whether the field is required on tag instances.",
700
+ },
701
+ description: {
702
+ type: "string",
703
+ description: "Field-level description; surfaced in the admin SPA.",
704
+ },
705
+ },
706
+ },
707
+ },
708
+ },
709
+ },
710
+ },
711
+ },
712
+ },
713
+ },
714
+ };
715
+ }