@openparachute/hub 0.7.0 → 0.7.1

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/src/invites.ts CHANGED
@@ -19,6 +19,24 @@
19
19
  * the createUser-then-stamp ordering so a createUser failure leaves the
20
20
  * invite re-usable.
21
21
  *
22
+ * Two invite shapes carry that authorization (plus the account-only shape):
23
+ * - provision_vault=1 — redemption provisions a NEW vault (optionally
24
+ * pre-named via `vault_name`) and assigns the redeemer at `role`
25
+ * (always 'write': the sole user of a fresh vault must hold write).
26
+ * - provision_vault=0 + vault_name — a SHARED-VAULT invite: redemption
27
+ * assigns the redeemer to the admin's EXISTING vault at `role`
28
+ * ('read' or 'write'). Issuing is host:admin-gated — the same
29
+ * authority that can already assign any user to any vault via
30
+ * `POST /api/users` / `PATCH /api/users/:id/vaults` — so the invite
31
+ * is a delivery mechanism for an admin-authorized assignment, not an
32
+ * escalation. The read-only role is enforced end-to-end: every mint
33
+ * path caps to `vaultVerbsForRole` (users.ts) and the vault's
34
+ * scope-guard refuses writes for a `vault:<name>:read` token.
35
+ *
36
+ * An invite may also pre-name the redeemer's USERNAME (`username` column,
37
+ * v13): the redemption form shows it read-only and the redeem handler
38
+ * enforces it. NULL = redeemer picks their own.
39
+ *
22
40
  * Single-use is enforced by stamping `used_at` on redemption — a replay
23
41
  * attempt sees the row with `used_at` set and `redeemInvite` throws
24
42
  * `InviteUsedError`. Revocation is a separate `revoked_at` stamp the admin
@@ -41,6 +59,11 @@ export interface Invite {
41
59
  createdBy: string | null;
42
60
  /** Pinned vault name, or null when the redeemer names their own vault. */
43
61
  vaultName: string | null;
62
+ /**
63
+ * Pre-named username the redeemer's account gets (ENFORCED at redeem),
64
+ * or null when the redeemer picks their own. v13.
65
+ */
66
+ username: string | null;
44
67
  /** `user_vaults.role` granted on redemption (`'write'` = owner). */
45
68
  role: string;
46
69
  /** Whether redemption provisions a NEW vault for the redeemer. */
@@ -86,6 +109,7 @@ interface Row {
86
109
  token: string;
87
110
  created_by: string | null;
88
111
  vault_name: string | null;
112
+ username: string | null;
89
113
  role: string;
90
114
  provision_vault: number;
91
115
  default_mirror: string | null;
@@ -101,6 +125,7 @@ function rowToInvite(r: Row): Invite {
101
125
  tokenHash: r.token,
102
126
  createdBy: r.created_by,
103
127
  vaultName: r.vault_name,
128
+ username: r.username,
104
129
  role: r.role,
105
130
  provisionVault: r.provision_vault === 1,
106
131
  defaultMirror: r.default_mirror,
@@ -130,6 +155,11 @@ export interface IssueInviteOpts {
130
155
  createdBy: string;
131
156
  /** Pinned vault name; omit/null to let the redeemer name their own. */
132
157
  vaultName?: string | null;
158
+ /**
159
+ * Pre-named username (ENFORCED at redeem); omit/null to let the redeemer
160
+ * pick their own. Caller validates the vocabulary + uniqueness.
161
+ */
162
+ username?: string | null;
133
163
  /** `user_vaults` role granted on redemption. Default `'write'` (owner). */
134
164
  role?: string;
135
165
  /** Provision a new vault on redemption. Default `true` (the primary flow). */
@@ -165,18 +195,20 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
165
195
  const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
166
196
  const role = opts.role ?? "write";
167
197
  const vaultName = opts.vaultName ?? null;
198
+ const username = opts.username ?? null;
168
199
  const provisionVault = opts.provisionVault ?? true;
169
200
  const defaultMirror = opts.defaultMirror ?? null;
170
201
 
171
202
  db.prepare(
172
203
  `INSERT INTO invites
173
- (token, created_by, vault_name, role, provision_vault, default_mirror,
204
+ (token, created_by, vault_name, username, role, provision_vault, default_mirror,
174
205
  expires_at, used_at, redeemed_user_id, revoked_at, created_at)
175
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
206
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
176
207
  ).run(
177
208
  tokenHash,
178
209
  opts.createdBy,
179
210
  vaultName,
211
+ username,
180
212
  role,
181
213
  provisionVault ? 1 : 0,
182
214
  defaultMirror,
@@ -190,6 +222,7 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
190
222
  tokenHash,
191
223
  createdBy: opts.createdBy,
192
224
  vaultName,
225
+ username,
193
226
  role,
194
227
  provisionVault,
195
228
  defaultMirror,
@@ -215,6 +248,40 @@ export function findInviteByHash(db: Database, tokenHash: string): Invite | null
215
248
  return row ? rowToInvite(row) : null;
216
249
  }
217
250
 
251
+ /**
252
+ * Is `username` already reserved by a PENDING pre-named invite (unredeemed,
253
+ * unrevoked, not yet expired)? Two pending invites pre-naming the same
254
+ * username would make the second one un-redeemable (the redeem path's
255
+ * uniqueness check fails permanently for an enforced name), so mint-time
256
+ * rejects the collision.
257
+ *
258
+ * Exact `=` comparison, deliberately NOT `COLLATE NOCASE` — an asymmetry
259
+ * with `getUserByUsernameCI` worth naming. The users-table CI lookup is
260
+ * defense in depth against legacy/hand-edited `users` rows that might carry
261
+ * mixed case from before the validator pinned lowercase. `invites.username`
262
+ * has no such legacy: the column is only ever written through the
263
+ * `validateUsername`-gated mint path (api-invites.ts), so every stored value
264
+ * is already lowercase, and the value compared against it went through the
265
+ * same validator. A hand-edited mixed-case invites row wouldn't reserve —
266
+ * but it also can't redeem: the redeem path re-runs `validateUsername` on
267
+ * the pre-named value and rejects it (the hand-edited-row backstop in
268
+ * account-setup.ts).
269
+ */
270
+ export function usernameReservedByPendingInvite(
271
+ db: Database,
272
+ username: string,
273
+ now: Date = new Date(),
274
+ ): boolean {
275
+ const row = db
276
+ .query<{ token: string }, [string, string]>(
277
+ `SELECT token FROM invites
278
+ WHERE username = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?
279
+ LIMIT 1`,
280
+ )
281
+ .get(username, now.toISOString());
282
+ return row !== null;
283
+ }
284
+
218
285
  /** List every invite, newest first, with derived status. */
219
286
  export function listInvites(
220
287
  db: Database,
package/src/jwt-sign.ts CHANGED
@@ -141,12 +141,18 @@ export interface SignRefreshTokenOpts {
141
141
  * when provisioning a connection (the webhook bearer + the channel reply
142
142
  * token). Registered so connection teardown can revoke them
143
143
  * (hub-module-boundary charter, registered-mint rule).
144
+ *
145
+ * `connection_credential` — standing tag-scoped vault credentials minted by
146
+ * a `kind: "credential"` connection (H4, surface-runtime design). Registered
147
+ * for the same reason; renewal revokes the prior jti and registers the new
148
+ * one, so exactly one live row exists per credential connection.
144
149
  */
145
150
  export type TokenCreatedVia =
146
151
  | "oauth_refresh"
147
152
  | "cli_mint"
148
153
  | "operator_mint"
149
- | "connection_provision";
154
+ | "connection_provision"
155
+ | "connection_credential";
150
156
 
151
157
  export interface SignedRefreshToken {
152
158
  /** Opaque token to return to the client. NOT recoverable from the DB. */
@@ -131,6 +131,45 @@ export interface ModuleAction {
131
131
  readonly provision?: unknown;
132
132
  }
133
133
 
134
+ /**
135
+ * A standing CREDENTIAL a module declares it can hold (H4, surface-runtime
136
+ * design / credential connections). Where an action's `scope` is a scope in
137
+ * the module's OWN namespace (minted for callbacks INTO the module), a
138
+ * credential declaration asks the hub to mint the module a standing
139
+ * tag-scoped token on a VAULT — operator-approved via
140
+ * `POST /admin/connections` with `kind: "credential"`.
141
+ *
142
+ * The `scope` field is a TEMPLATE, not a literal: `vault:{vault}:read` or
143
+ * `vault:{vault}:write` — the `{vault}` placeholder is filled by the
144
+ * operator's approval (which vault, which tags). Validation enforces the
145
+ * privilege-escalation guard at declaration time: ONLY the `vault` namespace,
146
+ * ONLY `read`/`write` verbs — never `admin`, never another module's
147
+ * namespace. (The POST handler re-checks the same rule, so a manifest read
148
+ * through a non-validating path can't smuggle a broader template.)
149
+ */
150
+ export interface ModuleCredential {
151
+ /** Credential identifier within the module, e.g. `vault`. */
152
+ readonly key: string;
153
+ /** Operator-facing label. */
154
+ readonly title: string;
155
+ readonly description?: string;
156
+ /**
157
+ * Scope template: `vault:{vault}:read` | `vault:{vault}:write`. The
158
+ * operator approval fills `{vault}` and supplies the tag scope.
159
+ */
160
+ readonly scope: string;
161
+ /**
162
+ * Daemon-root-relative HTTP endpoint (leading `/`) the hub POSTs the
163
+ * minted credential to over loopback (like the engine's channel-config
164
+ * delivery), authenticated with a short-lived `<module>:admin` bearer.
165
+ * Also receives the best-effort removal payload on teardown.
166
+ */
167
+ readonly endpoint: string;
168
+ }
169
+
170
+ /** The validated shape of a credential scope template. */
171
+ export const CREDENTIAL_SCOPE_TEMPLATE_RE = /^vault:\{vault\}:(read|write)$/;
172
+
134
173
  /**
135
174
  * One declared parameter of a {@link ConnectionTemplate} — the operator-chosen
136
175
  * blank in the template (e.g. WHICH vault, the channel name).
@@ -271,6 +310,16 @@ export interface ModuleManifest {
271
310
  * per-module rationale.
272
311
  */
273
312
  readonly stripPrefix?: boolean;
313
+ /**
314
+ * When `true`, the module's daemon accepts WebSocket upgrades and the hub's
315
+ * Bun-native upgrade bridge (H1, surface-runtime design) forwards
316
+ * `Upgrade: websocket` requests on the module's mounts. DENY BY DEFAULT:
317
+ * absent/false refuses upgrades (426) before they reach the daemon. The
318
+ * canonical capability declaration; modules also carry it onto their
319
+ * self-registered services.json row (`ServiceEntry.websocket`), and the hub
320
+ * honors either source.
321
+ */
322
+ readonly websocket?: boolean;
274
323
  /**
275
324
  * Discovery tier (2026-06-09 modular-UI architecture). When a module
276
325
  * declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
@@ -298,6 +347,8 @@ export interface ModuleManifest {
298
347
  readonly actions?: readonly ModuleAction[];
299
348
  /** Connection presets this module declares — see {@link ConnectionTemplate}. */
300
349
  readonly connectionTemplates?: readonly ConnectionTemplate[];
350
+ /** Standing vault credentials this module can hold — see {@link ModuleCredential} (H4). */
351
+ readonly credentials?: readonly ModuleCredential[];
301
352
  }
302
353
 
303
354
  export class ModuleManifestError extends Error {
@@ -562,6 +613,7 @@ export function validateModuleManifest(
562
613
  const events = asEvents(m.events, where);
563
614
  const actions = asActions(m.actions, where, name);
564
615
  const connectionTemplates = asConnectionTemplates(m.connectionTemplates, where);
616
+ const credentials = asCredentials(m.credentials, where);
565
617
  let stripPrefix: boolean | undefined;
566
618
  if (m.stripPrefix !== undefined) {
567
619
  if (typeof m.stripPrefix !== "boolean") {
@@ -569,6 +621,13 @@ export function validateModuleManifest(
569
621
  }
570
622
  stripPrefix = m.stripPrefix;
571
623
  }
624
+ let websocket: boolean | undefined;
625
+ if (m.websocket !== undefined) {
626
+ if (typeof m.websocket !== "boolean") {
627
+ throw new ModuleManifestError(`${where}: "websocket" must be a boolean if present`);
628
+ }
629
+ websocket = m.websocket;
630
+ }
572
631
 
573
632
  const out: ModuleManifest = { name, manifestName, port, paths, health };
574
633
  if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
@@ -590,6 +649,9 @@ export function validateModuleManifest(
590
649
  if (stripPrefix !== undefined) {
591
650
  (out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
592
651
  }
652
+ if (websocket !== undefined) {
653
+ (out as { websocket?: boolean }).websocket = websocket;
654
+ }
593
655
  if (focus !== undefined) (out as { focus?: ModuleFocus }).focus = focus;
594
656
  if (configUiUrl !== undefined) (out as { configUiUrl?: string }).configUiUrl = configUiUrl;
595
657
  if (adminCapabilities !== undefined) {
@@ -601,9 +663,54 @@ export function validateModuleManifest(
601
663
  (out as { connectionTemplates?: readonly ConnectionTemplate[] }).connectionTemplates =
602
664
  connectionTemplates;
603
665
  }
666
+ if (credentials !== undefined) {
667
+ (out as { credentials?: readonly ModuleCredential[] }).credentials = credentials;
668
+ }
604
669
  return out;
605
670
  }
606
671
 
672
+ /**
673
+ * Validate the optional `credentials` declaration (H4). The scope template
674
+ * is the privilege-escalation guard's declaration-time half: ONLY
675
+ * `vault:{vault}:read` / `vault:{vault}:write` — a module can never declare
676
+ * its way to `admin`, to a literal vault name (the operator picks the vault
677
+ * at approval), or to another module's namespace. The POST handler re-checks
678
+ * the same rule (defense in depth for manifests read through paths that skip
679
+ * this validator).
680
+ */
681
+ function asCredentials(v: unknown, where: string): readonly ModuleCredential[] | undefined {
682
+ if (v === undefined) return undefined;
683
+ if (!Array.isArray(v)) {
684
+ throw new ModuleManifestError(`${where}: "credentials" must be an array if present`);
685
+ }
686
+ return v.map((raw, i) => {
687
+ const at = `credentials[${i}]`;
688
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
689
+ throw new ModuleManifestError(`${where}: "${at}" must be an object`);
690
+ }
691
+ const c = raw as Record<string, unknown>;
692
+ const scope = asString(c.scope, where, `${at}.scope`);
693
+ if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(scope)) {
694
+ throw new ModuleManifestError(
695
+ `${where}: "${at}.scope" "${scope}" must be "vault:{vault}:read" or "vault:{vault}:write" — credential connections never grant admin or another namespace`,
696
+ );
697
+ }
698
+ const endpoint = asString(c.endpoint, where, `${at}.endpoint`);
699
+ if (!endpoint.startsWith("/")) {
700
+ throw new ModuleManifestError(`${where}: "${at}.endpoint" must start with "/"`);
701
+ }
702
+ const out: ModuleCredential = {
703
+ key: asString(c.key, where, `${at}.key`),
704
+ title: asString(c.title, where, `${at}.title`),
705
+ scope,
706
+ endpoint,
707
+ };
708
+ const description = asOptionalString(c.description, where, `${at}.description`);
709
+ if (description !== undefined) (out as { description?: string }).description = description;
710
+ return out;
711
+ });
712
+ }
713
+
607
714
  const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
608
715
 
609
716
  function asFocus(v: unknown, where: string): ModuleFocus | undefined {
@@ -180,10 +180,13 @@ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
180
180
  * mutation under `/admin/*`; keep this list in sync with the dispatch in
181
181
  * `hub-server.ts`):
182
182
  *
183
- * - `POST /admin/connections` + `DELETE /admin/connections/<id>`
184
- * (connection provision/teardown — the seam's canonical consumers are
185
- * channel's admin page and the hub SPA, both same-origin `fetch()` with
186
- * `credentials: "include"`)
183
+ * - `POST /admin/connections` + `DELETE /admin/connections/<id>` +
184
+ * `POST /admin/connections/<id>/approve`
185
+ * (connection provision/teardown/claim-approval the seam's canonical
186
+ * consumers are channel's admin page and the hub SPA, both same-origin
187
+ * `fetch()` with `credentials: "include"`. The Bearer-authed
188
+ * `/<id>/renew` + `/<id>/claim` siblings pass the belt via the
189
+ * Authorization carve-out below.)
187
190
  *
188
191
  * (The legacy `POST/DELETE /admin/channels` pair was belted here until
189
192
  * boundary D1 retired the endpoint — superseded by `/admin/connections`.)
@@ -71,6 +71,38 @@ function normalizeUiSubUnitStatus(value: string): UiSubUnitStatus | undefined {
71
71
  return UI_SUB_UNIT_STATUS_ALIASES[value];
72
72
  }
73
73
 
74
+ /**
75
+ * Who may load a UI sub-unit through the hub proxy (H3, surface-runtime
76
+ * design §12 — fixes parachute-surface#88, where `public` was declared but
77
+ * never enforced):
78
+ *
79
+ * "public" — anyone; no hub identity required (chrome strip off — H5).
80
+ * "hub-users" — a valid hub session cookie OR a hub-issued Bearer whose
81
+ * scopes satisfy the surface's `scopes_required` (the OR
82
+ * keeps installed PWAs working). THE DEFAULT when absent.
83
+ * "operator" — the first-admin session only.
84
+ * "surface" — the surface backend owns admission END-TO-END; the hub
85
+ * proxy passes every request through (backed surfaces —
86
+ * kit-authenticated via @openparachute/surface-server: hub
87
+ * JWTs / capability links / anon, deny-by-default). Chrome
88
+ * strip off like `public` — capability-link invitees are
89
+ * NOT hub users.
90
+ *
91
+ * Enforced at the hub proxy BEFORE forwarding (`src/audience-gate.ts`).
92
+ * Legacy boolean `public` on the wire is accepted one alias window:
93
+ * `public: true` → `"public"`, `public: false` → default. Fail-closed: an
94
+ * unrecognized `audience` value rejects the row (the lenient manifest read
95
+ * then drops it — unroutable beats accidentally public). Version-skew
96
+ * corollary: a surface declaring `audience: "surface"` registered against a
97
+ * hub OLDER than this value's introduction gets its row rejected by that
98
+ * same fail-closed rule — the mount 404s until the hub is upgraded. The
99
+ * surface-side meta-schema ships the value with a hub-version requirement
100
+ * note for exactly this reason.
101
+ */
102
+ export type UiAudience = "public" | "hub-users" | "operator" | "surface";
103
+
104
+ const UI_AUDIENCE_VALUES: readonly UiAudience[] = ["public", "hub-users", "operator", "surface"];
105
+
74
106
  /**
75
107
  * A sub-unit beneath a module — used today by parachute-app to surface each
76
108
  * hosted UI as its own discoverable row under the App module, and the shape
@@ -129,6 +161,20 @@ export interface UiSubUnit {
129
161
  oauthClientId?: string;
130
162
  /** UI sub-unit lifecycle state. Absent → discovery treats as "active". */
131
163
  status?: UiSubUnitStatus;
164
+ /**
165
+ * Audience exposure (H3). Absent → "hub-users". The legacy boolean
166
+ * `public` field is normalized into this on read (true → "public") for
167
+ * one alias window. See {@link UiAudience}.
168
+ */
169
+ audience?: UiAudience;
170
+ /**
171
+ * OAuth scopes the UI declares as required (surface-host stamps these from
172
+ * the surface's meta.json `scopes_required`, e.g. `["vault:*:read"]` —
173
+ * `*` is a single-segment wildcard). The audience gate's Bearer branch
174
+ * checks a presented token's scopes against these. Snake_case to match
175
+ * the wire shape surface already writes.
176
+ */
177
+ scopes_required?: string[];
132
178
  }
133
179
 
134
180
  export interface ServiceEntry {
@@ -170,6 +216,16 @@ export interface ServiceEntry {
170
216
  * bridges the gap. Tracked in parachute-scribe (separate issue).
171
217
  */
172
218
  stripPrefix?: boolean;
219
+ /**
220
+ * When `true`, the module's daemon accepts WebSocket upgrades and the hub's
221
+ * Bun-native upgrade bridge (H1, surface-runtime design) will forward
222
+ * `Upgrade: websocket` requests on this module's mounts to it. DENY BY
223
+ * DEFAULT: absent/false means an upgrade request to this mount is refused
224
+ * (426) without ever reaching the daemon. Modules declare this on their
225
+ * `.parachute/module.json` (the install-time contract) and carry it onto
226
+ * their self-registered services.json row; the hub honors either source.
227
+ */
228
+ websocket?: boolean;
173
229
  /**
174
230
  * Sub-units hosted under this module — parachute-app's bag of UIs, and
175
231
  * the shape vault is expected to adopt for per-instance metadata in a
@@ -282,6 +338,10 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
282
338
  if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
283
339
  throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
284
340
  }
341
+ const websocket = e.websocket;
342
+ if (websocket !== undefined && typeof websocket !== "boolean") {
343
+ throw new ServicesManifestError(`${where}: "websocket" must be a boolean if present`);
344
+ }
285
345
  const uis = e.uis;
286
346
  const validatedUis = validateUis(uis, where);
287
347
  const validatedStartError = validateStartError(e.lastStartError, where);
@@ -291,6 +351,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
291
351
  if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
292
352
  if (installDir !== undefined) entry.installDir = installDir;
293
353
  if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
354
+ if (websocket !== undefined) entry.websocket = websocket;
294
355
  if (validatedUis !== undefined) entry.uis = validatedUis;
295
356
  if (validatedStartError !== undefined) entry.lastStartError = validatedStartError;
296
357
  return entry;
@@ -405,12 +466,48 @@ function validateUiSubUnit(raw: unknown, where: string): UiSubUnit {
405
466
  }
406
467
  normalizedStatus = norm;
407
468
  }
469
+ // Audience (H3). Explicit `audience` wins; the legacy boolean `public` is
470
+ // accepted as an alias for one release window (true → "public"; false →
471
+ // the default, i.e. no field). FAIL-CLOSED on malformed values: throwing
472
+ // here makes the lenient manifest read drop the whole row — an unroutable
473
+ // surface beats one that silently falls open to the wrong audience.
474
+ let audience: UiAudience | undefined;
475
+ if (u.audience !== undefined) {
476
+ if (
477
+ typeof u.audience !== "string" ||
478
+ !(UI_AUDIENCE_VALUES as readonly string[]).includes(u.audience)
479
+ ) {
480
+ throw new ServicesManifestError(
481
+ `${where}: "audience" must be "public" | "hub-users" | "operator" | "surface" if present (got ${JSON.stringify(u.audience)})`,
482
+ );
483
+ }
484
+ audience = u.audience as UiAudience;
485
+ } else if (u.public !== undefined) {
486
+ if (typeof u.public !== "boolean") {
487
+ throw new ServicesManifestError(`${where}: legacy "public" must be a boolean if present`);
488
+ }
489
+ if (u.public) audience = "public";
490
+ }
491
+ let scopesRequired: string[] | undefined;
492
+ if (u.scopes_required !== undefined) {
493
+ if (
494
+ !Array.isArray(u.scopes_required) ||
495
+ u.scopes_required.some((s) => typeof s !== "string" || s.length === 0)
496
+ ) {
497
+ throw new ServicesManifestError(
498
+ `${where}: "scopes_required" must be an array of non-empty strings if present`,
499
+ );
500
+ }
501
+ scopesRequired = u.scopes_required as string[];
502
+ }
408
503
  const out: UiSubUnit = { displayName, path };
409
504
  if (tagline !== undefined) out.tagline = tagline;
410
505
  if (iconUrl !== undefined) out.iconUrl = iconUrl;
411
506
  if (version !== undefined) out.version = version;
412
507
  if (oauthClientId !== undefined) out.oauthClientId = oauthClientId;
413
508
  if (normalizedStatus !== undefined) out.status = normalizedStatus;
509
+ if (audience !== undefined) out.audience = audience;
510
+ if (scopesRequired !== undefined) out.scopes_required = scopesRequired;
414
511
  return out;
415
512
  }
416
513