@openparachute/hub 0.6.5-rc.7 → 0.7.0

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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-db-liveness.test.ts +12 -7
  12. package/src/__tests__/hub-server.test.ts +319 -21
  13. package/src/__tests__/invites.test.ts +27 -0
  14. package/src/__tests__/module-manifest.test.ts +305 -8
  15. package/src/__tests__/serve-boot.test.ts +133 -2
  16. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  17. package/src/__tests__/setup-gate.test.ts +13 -7
  18. package/src/__tests__/setup-wizard.test.ts +228 -1
  19. package/src/__tests__/vault-name.test.ts +20 -5
  20. package/src/__tests__/well-known.test.ts +44 -8
  21. package/src/account-vault-admin-token.ts +43 -14
  22. package/src/admin-channel-token.ts +135 -0
  23. package/src/admin-connections.ts +980 -0
  24. package/src/admin-module-token.ts +197 -0
  25. package/src/admin-vaults.ts +390 -12
  26. package/src/api-hub-upgrade.ts +4 -3
  27. package/src/api-modules-ops.ts +41 -16
  28. package/src/api-modules.ts +238 -116
  29. package/src/api-tokens.ts +8 -5
  30. package/src/commands/serve-boot.ts +80 -3
  31. package/src/commands/setup.ts +4 -4
  32. package/src/connections-store.ts +161 -0
  33. package/src/grants.ts +50 -0
  34. package/src/hub-db-liveness.ts +33 -17
  35. package/src/hub-server.ts +354 -61
  36. package/src/invites.ts +22 -0
  37. package/src/jwt-sign.ts +41 -1
  38. package/src/module-manifest.ts +429 -23
  39. package/src/origin-check.ts +106 -0
  40. package/src/proxy-error-ui.ts +1 -1
  41. package/src/service-spec.ts +132 -41
  42. package/src/setup-wizard.ts +68 -6
  43. package/src/users.ts +11 -0
  44. package/src/vault-name.ts +27 -7
  45. package/src/well-known.ts +41 -33
  46. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  47. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  48. package/web/ui/dist/index.html +2 -2
  49. package/src/__tests__/api-modules-config.test.ts +0 -882
  50. package/src/api-modules-config.ts +0 -421
  51. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  52. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/jwt-sign.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  importSPKI,
28
28
  jwtVerify,
29
29
  } from "jose";
30
+ import { vaultScopeName } from "./scope-explanations.ts";
30
31
  import { getActiveSigningKey, getAllPublicKeys } from "./signing-keys.ts";
31
32
 
32
33
  export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
@@ -135,8 +136,17 @@ export interface SignRefreshTokenOpts {
135
136
  * one table for refresh tokens, one for CLI-minted access tokens, one
136
137
  * for operator tokens. Different mint paths = different rows; revocation
137
138
  * lookup + revocation list are uniform across all of them.
139
+ *
140
+ * `connection_provision` — long-lived tokens the Connections engine mints
141
+ * when provisioning a connection (the webhook bearer + the channel reply
142
+ * token). Registered so connection teardown can revoke them
143
+ * (hub-module-boundary charter, registered-mint rule).
138
144
  */
139
- export type TokenCreatedVia = "oauth_refresh" | "cli_mint" | "operator_mint";
145
+ export type TokenCreatedVia =
146
+ | "oauth_refresh"
147
+ | "cli_mint"
148
+ | "operator_mint"
149
+ | "connection_provision";
140
150
 
141
151
  export interface SignedRefreshToken {
142
152
  /** Opaque token to return to the client. NOT recoverable from the DB. */
@@ -267,6 +277,36 @@ export function revokeTokenByJti(db: Database, jti: string, now: Date): boolean
267
277
  return Number(res.changes) > 0;
268
278
  }
269
279
 
280
+ /**
281
+ * Revoke every un-revoked tokens row whose recorded scopes NAME the given
282
+ * vault (`vault:<name>:<verb>`) — the B1 vault-delete registry sweep
283
+ * (2026-06-09 hub-module-boundary migration, lifecycle symmetry).
284
+ *
285
+ * Matching is EXACT scope-segment comparison via `vaultScopeName` — NEVER
286
+ * SQL `LIKE`: `_` in a vault name is a LIKE single-char wildcard, so a
287
+ * `LIKE '%vault:my_vault:%'` sweep for vault `my_vault` would also revoke
288
+ * `myxvault`-scoped tokens. We read candidate rows and match in JS instead.
289
+ * Unnamed scopes (`vault:read`) don't name an instance and are untouched.
290
+ *
291
+ * Returns the number of rows newly revoked. Idempotent (already-revoked
292
+ * rows are filtered by the WHERE and by `revokeTokenByJti`).
293
+ */
294
+ export function revokeTokensNamingVault(db: Database, vaultName: string, now: Date): number {
295
+ const rows = db
296
+ .query<{ jti: string; scopes: string }, []>(
297
+ "SELECT jti, scopes FROM tokens WHERE revoked_at IS NULL",
298
+ )
299
+ .all();
300
+ let revoked = 0;
301
+ for (const row of rows) {
302
+ const scopes = row.scopes.split(" ").filter((s) => s.length > 0);
303
+ if (scopes.some((s) => vaultScopeName(s) === vaultName)) {
304
+ if (revokeTokenByJti(db, row.jti, now)) revoked++;
305
+ }
306
+ }
307
+ return revoked;
308
+ }
309
+
270
310
  /**
271
311
  * Snapshot of currently-revoked-and-not-yet-expired jtis. Powers the
272
312
  * `/.well-known/parachute-revocation.json` endpoint. Already-expired jtis
@@ -65,6 +65,131 @@ export interface ConfigSchema {
65
65
  readonly required?: readonly string[];
66
66
  }
67
67
 
68
+ /**
69
+ * Discovery tier (2026-06-09 modular-UI architecture). `core` modules are the
70
+ * product surface (vault / scribe / hub / surface); `experimental` modules
71
+ * (channel / runner / others) render in a de-emphasized group on the Modules
72
+ * screen. **Show all; never hide** — `focus` only sorts + labels.
73
+ *
74
+ * Absent in a `module.json` ⇒ the hub falls back to its default map (see
75
+ * `service-spec.focusForShort`), which defaults unlisted modules to
76
+ * `experimental`.
77
+ */
78
+ export type ModuleFocus = "core" | "experimental";
79
+
80
+ /**
81
+ * An event a module EMITS — the left-hand side of a Connection (2026-06-09
82
+ * modular-UI architecture, P5). Declared in `module.json`; the hub's
83
+ * Connections surface lists these so an operator can wire
84
+ * "when [event] in [module] → do [action] in [module]". `filterSchema` is an
85
+ * optional JSON-Schema describing the per-event filter an operator can set
86
+ * (e.g. a tag filter on `vault.note.created`). Minimal at P1 — the hub only
87
+ * needs to round-trip the declaration; richer typing lands with P5.
88
+ */
89
+ export interface ModuleEvent {
90
+ /** Event identifier within the module, e.g. `note.created`. */
91
+ readonly key: string;
92
+ /** Operator-facing label. */
93
+ readonly title: string;
94
+ /** Optional JSON-Schema for the per-event filter an operator may set. */
95
+ readonly filterSchema?: unknown;
96
+ }
97
+
98
+ /**
99
+ * An action a module ACCEPTS — the right-hand side of a Connection (2026-06-09
100
+ * modular-UI architecture, P5). `inputSchema` is an optional JSON-Schema for
101
+ * the action's input; `provision` is an opaque (at P1) descriptor of how the
102
+ * hub wires the action when a Connection is created (e.g. register a vault
103
+ * trigger). Both are passed through untyped here — the hub only round-trips
104
+ * the declaration at P1; P5 gives them structure.
105
+ */
106
+ export interface ModuleAction {
107
+ /** Action identifier within the module, e.g. `message.send`. */
108
+ readonly key: string;
109
+ /** Operator-facing label. */
110
+ readonly title: string;
111
+ /** Optional JSON-Schema for the action's input. */
112
+ readonly inputSchema?: unknown;
113
+ /**
114
+ * The module-relative HTTP endpoint the hub's Connections engine calls when
115
+ * this action fires (P5). For a `vault-trigger` provision, this becomes the
116
+ * vault trigger's `action.webhook`, hub-proxied under the module's mount:
117
+ * `<hub-origin>/<mount><endpoint>`. Declaring it here — rather than
118
+ * hardcoding a per-module path in the hub — is what makes the engine general.
119
+ * Channel ships `"/api/vault/inbound"`.
120
+ */
121
+ readonly endpoint?: string;
122
+ /**
123
+ * The OAuth scope the hub mints into the action webhook's `Authorization:
124
+ * Bearer` (P5). For a `vault-trigger`, this is persisted as the trigger's
125
+ * long-lived `action.auth.bearer` scope — the credential the sink module
126
+ * validates on every callback. Sourced from the action declaration so the
127
+ * hub never hardcodes a per-module scope. Channel ships `"channel:send"`.
128
+ */
129
+ readonly scope?: string;
130
+ /** Opaque (P1) descriptor of how the hub provisions this action. */
131
+ readonly provision?: unknown;
132
+ }
133
+
134
+ /**
135
+ * One declared parameter of a {@link ConnectionTemplate} — the operator-chosen
136
+ * blank in the template (e.g. WHICH vault, the channel name).
137
+ */
138
+ export interface ConnectionTemplateParameter {
139
+ /** Parameter identifier within the template, e.g. `vault`, `channel`. */
140
+ readonly key: string;
141
+ /**
142
+ * Where the chosen value lands on the connection body, e.g. `source.vault`
143
+ * or `sink.params.channel`. Opaque to the hub at P1 — builder UIs interpret
144
+ * the two shapes above; anything else rides through for future targets.
145
+ */
146
+ readonly target: string;
147
+ /** Operator-facing label. */
148
+ readonly title?: string;
149
+ readonly description?: string;
150
+ /** Optional pre-fill example a builder UI may show for this parameter. */
151
+ readonly example?: string;
152
+ }
153
+
154
+ /**
155
+ * A connection PRESET a module declares in `module.json` (boundary D2).
156
+ *
157
+ * Two shapes ship today, discriminated by presence of `source` + `sink`:
158
+ * - **event→action preset** (channel's `link-to-vault`): `source` (a module
159
+ * event + optional filter) + `sink` (a module action). The hub's
160
+ * Connections builder offers these as one-click pre-fills.
161
+ * - **config link** (scribe's `link-to-vault`, `kind: "config"`): no
162
+ * `source`/`sink` — a module-owned config flow described by other fields
163
+ * (`provider`/`target`, which ride through `extra`-style and are NOT
164
+ * interpreted by the hub). These are consumed by the module's own UI,
165
+ * not the hub builder.
166
+ *
167
+ * Declaration-driven so the hub SPA never hardcodes a per-module preset (the
168
+ * charter's per-module-view test); the hub only round-trips these through
169
+ * `/api/connections/catalog` (event→action presets only).
170
+ */
171
+ export interface ConnectionTemplate {
172
+ /** Template identifier within the module, e.g. `link-to-vault`. */
173
+ readonly key: string;
174
+ /** Operator-facing label. */
175
+ readonly title: string;
176
+ readonly description?: string;
177
+ /** Provenance label for connections created from this template. */
178
+ readonly requestedBy?: string;
179
+ /** Optional discriminator — scribe ships `"config"`. Absent = event→action. */
180
+ readonly kind?: string;
181
+ /** The source event the template pre-fills (+ optional filter, opaque). */
182
+ readonly source?: {
183
+ readonly module: string;
184
+ readonly event: string;
185
+ readonly filter?: unknown;
186
+ };
187
+ /** The sink action the template pre-fills. */
188
+ readonly sink?: { readonly module: string; readonly action: string };
189
+ /** Operator-chosen blanks. */
190
+ readonly parameters?: readonly ConnectionTemplateParameter[];
191
+ }
192
+
68
193
  export interface ModuleManifest {
69
194
  /** Stable ecosystem identifier — `[a-z][a-z0-9-]*`, also the services.json key. */
70
195
  readonly name: string;
@@ -99,12 +224,18 @@ export interface ModuleManifest {
99
224
  * Where the module's admin UI lives. Hub renders a "Manage" link when set
100
225
  * (see `parachute-patterns/patterns/module-json-extensibility.md`).
101
226
  *
102
- * Two shapes:
103
- * - A relative path (e.g. `"/admin"`) — hub resolves against the module's
104
- * mounted URL: `<module-url><managementUrl>`. Most first-party modules
105
- * take this path so the admin UI rides the same Tailscale Funnel cap.
106
- * - A full absolute URL — hub uses verbatim. Escape hatch for modules
107
- * whose admin UI is hosted off-origin.
227
+ * Three shapes (unified URL-resolution semantics — B4 of the 2026-06-09
228
+ * hub-module-boundary migration):
229
+ * - A full http(s) URL used verbatim. Escape hatch for modules whose
230
+ * admin UI is hosted off-origin.
231
+ * - A leading-`/` path (e.g. `"/scribe/admin"`) ORIGIN-ABSOLUTE, used
232
+ * verbatim against the hub origin.
233
+ * - A relative path (e.g. `"admin/"`) — the PER-INSTANCE form; hub joins
234
+ * it under the module's mounted URL: `<module-url>/<managementUrl>`.
235
+ *
236
+ * COMPAT SHIM (one release): the literal legacy `"/admin"`/`"/admin/"` on a
237
+ * vault entry is treated as the old per-instance relative form (mount-join)
238
+ * — deployed vaults still declare it until the vault wave ships.
108
239
  *
109
240
  * Absent = no link rendered (CLI-only management). Same back-compat rule
110
241
  * as `hasAuth` / `init` / `urlForEntry`.
@@ -140,6 +271,33 @@ export interface ModuleManifest {
140
271
  * per-module rationale.
141
272
  */
142
273
  readonly stripPrefix?: boolean;
274
+ /**
275
+ * Discovery tier (2026-06-09 modular-UI architecture). When a module
276
+ * declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
277
+ * falls back to `service-spec.focusForShort` (vault/scribe/hub/surface →
278
+ * `core`, everything else → `experimental`). **Show all; never hide** —
279
+ * `focus` only groups + de-emphasizes. Additive + back-compatible.
280
+ */
281
+ readonly focus?: ModuleFocus;
282
+ /**
283
+ * Where the module's OWN config/admin surface lives — the module renders it,
284
+ * the hub frames/links it (2026-06-09 modular-UI architecture, P3). Same
285
+ * path-or-absolute-URL shape as `managementUrl` (distinct field: `managementUrl`
286
+ * predates this; `configUiUrl` is the canonical config-surface declaration the
287
+ * config shell consumes). Optional + back-compatible.
288
+ */
289
+ readonly configUiUrl?: string;
290
+ /**
291
+ * Free-form capability hints for the config shell, e.g. `["config",
292
+ * "credentials", "logs"]`. Metadata only at P1 — the hub round-trips it.
293
+ */
294
+ readonly adminCapabilities?: readonly string[];
295
+ /** Events this module EMITS — Connections left-hand side (P5). */
296
+ readonly events?: readonly ModuleEvent[];
297
+ /** Actions this module ACCEPTS — Connections right-hand side (P5). */
298
+ readonly actions?: readonly ModuleAction[];
299
+ /** Connection presets this module declares — see {@link ConnectionTemplate}. */
300
+ readonly connectionTemplates?: readonly ConnectionTemplate[];
143
301
  }
144
302
 
145
303
  export class ModuleManifestError extends Error {
@@ -395,6 +553,15 @@ export function validateModuleManifest(
395
553
  const configSchema = asConfigSchema(m.configSchema, where);
396
554
  const managementUrl = asManagementUrl(m.managementUrl, where);
397
555
  const uiUrl = asUiUrl(m.uiUrl, where);
556
+ const focus = asFocus(m.focus, where);
557
+ const configUiUrl = asPathOrUrl(m.configUiUrl, where, "configUiUrl");
558
+ const adminCapabilities =
559
+ m.adminCapabilities === undefined
560
+ ? undefined
561
+ : asStringArray(m.adminCapabilities, where, "adminCapabilities");
562
+ const events = asEvents(m.events, where);
563
+ const actions = asActions(m.actions, where, name);
564
+ const connectionTemplates = asConnectionTemplates(m.connectionTemplates, where);
398
565
  let stripPrefix: boolean | undefined;
399
566
  if (m.stripPrefix !== undefined) {
400
567
  if (typeof m.stripPrefix !== "boolean") {
@@ -423,9 +590,204 @@ export function validateModuleManifest(
423
590
  if (stripPrefix !== undefined) {
424
591
  (out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
425
592
  }
593
+ if (focus !== undefined) (out as { focus?: ModuleFocus }).focus = focus;
594
+ if (configUiUrl !== undefined) (out as { configUiUrl?: string }).configUiUrl = configUiUrl;
595
+ if (adminCapabilities !== undefined) {
596
+ (out as { adminCapabilities?: readonly string[] }).adminCapabilities = adminCapabilities;
597
+ }
598
+ if (events !== undefined) (out as { events?: readonly ModuleEvent[] }).events = events;
599
+ if (actions !== undefined) (out as { actions?: readonly ModuleAction[] }).actions = actions;
600
+ if (connectionTemplates !== undefined) {
601
+ (out as { connectionTemplates?: readonly ConnectionTemplate[] }).connectionTemplates =
602
+ connectionTemplates;
603
+ }
426
604
  return out;
427
605
  }
428
606
 
607
+ const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
608
+
609
+ function asFocus(v: unknown, where: string): ModuleFocus | undefined {
610
+ if (v === undefined) return undefined;
611
+ if (typeof v !== "string" || !MODULE_FOCUS_VALUES.has(v as ModuleFocus)) {
612
+ throw new ModuleManifestError(`${where}: "focus" must be "core" | "experimental" if present`);
613
+ }
614
+ return v as ModuleFocus;
615
+ }
616
+
617
+ function asEvents(v: unknown, where: string): readonly ModuleEvent[] | undefined {
618
+ if (v === undefined) return undefined;
619
+ if (!Array.isArray(v)) {
620
+ throw new ModuleManifestError(`${where}: "events" must be an array if present`);
621
+ }
622
+ return v.map((raw, i) => {
623
+ const at = `events[${i}]`;
624
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
625
+ throw new ModuleManifestError(`${where}: "${at}" must be an object`);
626
+ }
627
+ const e = raw as Record<string, unknown>;
628
+ const out: ModuleEvent = {
629
+ key: asString(e.key, where, `${at}.key`),
630
+ title: asString(e.title, where, `${at}.title`),
631
+ };
632
+ if (e.filterSchema !== undefined) {
633
+ (out as { filterSchema?: unknown }).filterSchema = e.filterSchema;
634
+ }
635
+ return out;
636
+ });
637
+ }
638
+
639
+ function asActions(
640
+ v: unknown,
641
+ where: string,
642
+ /** Declaring module's name — enforces the `action.scope` namespace rule. */
643
+ name: string,
644
+ ): readonly ModuleAction[] | undefined {
645
+ if (v === undefined) return undefined;
646
+ if (!Array.isArray(v)) {
647
+ throw new ModuleManifestError(`${where}: "actions" must be an array if present`);
648
+ }
649
+ return v.map((raw, i) => {
650
+ const at = `actions[${i}]`;
651
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
652
+ throw new ModuleManifestError(`${where}: "${at}" must be an object`);
653
+ }
654
+ const a = raw as Record<string, unknown>;
655
+ const out: ModuleAction = {
656
+ key: asString(a.key, where, `${at}.key`),
657
+ title: asString(a.title, where, `${at}.title`),
658
+ };
659
+ if (a.inputSchema !== undefined) (out as { inputSchema?: unknown }).inputSchema = a.inputSchema;
660
+ if (a.endpoint !== undefined) {
661
+ const ep = asString(a.endpoint, where, `${at}.endpoint`);
662
+ if (!ep.startsWith("/")) {
663
+ throw new ModuleManifestError(`${where}: "${at}.endpoint" must start with "/"`);
664
+ }
665
+ (out as { endpoint?: string }).endpoint = ep;
666
+ }
667
+ if (a.scope !== undefined) {
668
+ const scope = asString(a.scope, where, `${at}.scope`);
669
+ // Scope-namespace rule (mirrors `scopes.defines` above): an action's
670
+ // `scope` is minted by the hub into a 90-day webhook bearer presented to
671
+ // THIS module's own endpoint, which validates `aud:<name>` + a scope in
672
+ // its own namespace. A legitimate `action.scope` is therefore always in
673
+ // the declaring module's namespace (channel.message.deliver → channel:send).
674
+ // Enforcing `<ns> === name` blocks a malicious module declaring e.g.
675
+ // `vault:default:admin` and tricking the hub into minting a cross-module
676
+ // privilege-escalating token when an operator wires a Connection to it.
677
+ // Cross-module tokens a sink legitimately needs for OTHER purposes (e.g.
678
+ // channel's reply path needs `vault:write`) are minted separately by the
679
+ // engine, NOT declared here.
680
+ const colon = scope.indexOf(":");
681
+ if (colon <= 0) {
682
+ throw new ModuleManifestError(
683
+ `${where}: "${at}.scope" "${scope}" must be namespaced as "<name>:<verb>"`,
684
+ );
685
+ }
686
+ const ns = scope.slice(0, colon);
687
+ if (ns !== name) {
688
+ throw new ModuleManifestError(
689
+ `${where}: "${at}.scope" "${scope}" namespace "${ns}" does not match module name "${name}"`,
690
+ );
691
+ }
692
+ (out as { scope?: string }).scope = scope;
693
+ }
694
+ if (a.provision !== undefined) (out as { provision?: unknown }).provision = a.provision;
695
+ return out;
696
+ });
697
+ }
698
+
699
+ /**
700
+ * Validate the optional `connectionTemplates` declaration (boundary D2).
701
+ * Light-touch like `events`/`actions` at P1 — the hub only round-trips these
702
+ * to `/api/connections/catalog`; `filter` rides through opaque (it's the same
703
+ * shape the connection body's `source.filter` takes).
704
+ *
705
+ * `source`/`sink` are OPTIONAL: scribe ships a `kind: "config"` template with
706
+ * neither (a module-owned config flow, not an event→action preset) — a strict
707
+ * requirement here would make every real-manifest read throw for scribe.
708
+ * When present, their inner shapes are validated.
709
+ */
710
+ function asConnectionTemplates(
711
+ v: unknown,
712
+ where: string,
713
+ ): readonly ConnectionTemplate[] | undefined {
714
+ if (v === undefined) return undefined;
715
+ if (!Array.isArray(v)) {
716
+ throw new ModuleManifestError(`${where}: "connectionTemplates" must be an array if present`);
717
+ }
718
+ return v.map((raw, i) => {
719
+ const at = `connectionTemplates[${i}]`;
720
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
721
+ throw new ModuleManifestError(`${where}: "${at}" must be an object`);
722
+ }
723
+ const t = raw as Record<string, unknown>;
724
+ let outSource: NonNullable<ConnectionTemplate["source"]> | undefined;
725
+ if (t.source !== undefined) {
726
+ const source = t.source;
727
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
728
+ throw new ModuleManifestError(`${where}: "${at}.source" must be an object if present`);
729
+ }
730
+ const src = source as Record<string, unknown>;
731
+ outSource = {
732
+ module: asString(src.module, where, `${at}.source.module`),
733
+ event: asString(src.event, where, `${at}.source.event`),
734
+ ...(src.filter !== undefined ? { filter: src.filter } : {}),
735
+ };
736
+ }
737
+ let outSink: NonNullable<ConnectionTemplate["sink"]> | undefined;
738
+ if (t.sink !== undefined) {
739
+ const sink = t.sink;
740
+ if (!sink || typeof sink !== "object" || Array.isArray(sink)) {
741
+ throw new ModuleManifestError(`${where}: "${at}.sink" must be an object if present`);
742
+ }
743
+ const snk = sink as Record<string, unknown>;
744
+ outSink = {
745
+ module: asString(snk.module, where, `${at}.sink.module`),
746
+ action: asString(snk.action, where, `${at}.sink.action`),
747
+ };
748
+ }
749
+ const out: ConnectionTemplate = {
750
+ key: asString(t.key, where, `${at}.key`),
751
+ title: asString(t.title, where, `${at}.title`),
752
+ };
753
+ if (outSource !== undefined) {
754
+ (out as { source?: ConnectionTemplate["source"] }).source = outSource;
755
+ }
756
+ if (outSink !== undefined) (out as { sink?: ConnectionTemplate["sink"] }).sink = outSink;
757
+ const kind = asOptionalString(t.kind, where, `${at}.kind`);
758
+ if (kind !== undefined) (out as { kind?: string }).kind = kind;
759
+ const description = asOptionalString(t.description, where, `${at}.description`);
760
+ if (description !== undefined) (out as { description?: string }).description = description;
761
+ const requestedBy = asOptionalString(t.requestedBy, where, `${at}.requestedBy`);
762
+ if (requestedBy !== undefined) (out as { requestedBy?: string }).requestedBy = requestedBy;
763
+ if (t.parameters !== undefined) {
764
+ if (!Array.isArray(t.parameters)) {
765
+ throw new ModuleManifestError(`${where}: "${at}.parameters" must be an array if present`);
766
+ }
767
+ const parameters = t.parameters.map((p, j) => {
768
+ const pat = `${at}.parameters[${j}]`;
769
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
770
+ throw new ModuleManifestError(`${where}: "${pat}" must be an object`);
771
+ }
772
+ const pr = p as Record<string, unknown>;
773
+ const param: ConnectionTemplateParameter = {
774
+ key: asString(pr.key, where, `${pat}.key`),
775
+ target: asString(pr.target, where, `${pat}.target`),
776
+ };
777
+ const title = asOptionalString(pr.title, where, `${pat}.title`);
778
+ if (title !== undefined) (param as { title?: string }).title = title;
779
+ const pdesc = asOptionalString(pr.description, where, `${pat}.description`);
780
+ if (pdesc !== undefined) (param as { description?: string }).description = pdesc;
781
+ const example = asOptionalString(pr.example, where, `${pat}.example`);
782
+ if (example !== undefined) (param as { example?: string }).example = example;
783
+ return param;
784
+ });
785
+ (out as { parameters?: readonly ConnectionTemplateParameter[] }).parameters = parameters;
786
+ }
787
+ return out;
788
+ });
789
+ }
790
+
429
791
  function asManagementUrl(v: unknown, where: string): string | undefined {
430
792
  return asPathOrUrl(v, where, "managementUrl");
431
793
  }
@@ -435,33 +797,77 @@ function asUiUrl(v: unknown, where: string): string | undefined {
435
797
  }
436
798
 
437
799
  /**
438
- * Validate a "path or http(s) URL" field. Both `managementUrl` and `uiUrl`
439
- * follow the same shape per the module-json-extensibility pattern doc;
440
- * factored so the next URL-shaped field doesn't have to copy-paste.
800
+ * Validate a "path or http(s) URL" field. `managementUrl`, `uiUrl`, and
801
+ * `configUiUrl` follow the same shape per the module-json-extensibility
802
+ * pattern doc; factored so the next URL-shaped field doesn't have to
803
+ * copy-paste.
804
+ *
805
+ * Three valid shapes (unified URL-resolution semantics, B4 of the 2026-06-09
806
+ * hub-module-boundary migration):
807
+ * - a full http(s) URL — resolvers use it verbatim;
808
+ * - an origin-absolute path starting with a single "/" — resolvers use it
809
+ * verbatim against the hub origin;
810
+ * - a RELATIVE path (no leading slash, e.g. `"admin/"`) — the per-instance
811
+ * form; resolvers join it under the module's mount. Constrained so it can
812
+ * only deepen its mount: no `..` segments, no URL scheme.
813
+ *
814
+ * Rejected: protocol-relative forms like `"//evil.com"` — they start with "/"
815
+ * but `new URL("//evil.com", base)` resolves to the foreign origin, which
816
+ * would let a malicious module render an off-origin tile and turn the
817
+ * discovery page into an open-redirect surface.
441
818
  */
442
819
  function asPathOrUrl(v: unknown, where: string, field: string): string | undefined {
443
820
  if (v === undefined) return undefined;
444
821
  if (typeof v !== "string" || v.length === 0) {
445
822
  throw new ModuleManifestError(`${where}: "${field}" must be a non-empty string if present`);
446
823
  }
447
- // Two valid shapes: an absolute path starting with a single "/" or a full
448
- // http(s) URL. Reject protocol-relative forms like "//evil.com" — they
449
- // start with "/" but `new URL("//evil.com", base)` resolves to the foreign
450
- // origin, which would let a malicious module render an off-origin tile and
451
- // turn the discovery page into an open-redirect surface.
452
- if (v.startsWith("/") && !v.startsWith("//")) return v;
453
- try {
454
- const u = new URL(v);
455
- if (u.protocol !== "http:" && u.protocol !== "https:") {
456
- throw new ModuleManifestError(`${where}: "${field}" absolute form must use http: or https:`);
824
+ if (v.startsWith("//")) {
825
+ throw new ModuleManifestError(
826
+ `${where}: "${field}" must not be protocol-relative ("//..." resolves off-origin)`,
827
+ );
828
+ }
829
+ // Origin-absolute path — verbatim.
830
+ if (v.startsWith("/")) return v;
831
+ // Scheme-bearing string must be a well-formed http(s) URL. Anything else
832
+ // ("ftp://...", "javascript:...") is rejected rather than smuggled through
833
+ // as a "relative path".
834
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v)) {
835
+ try {
836
+ const u = new URL(v);
837
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
838
+ throw new ModuleManifestError(
839
+ `${where}: "${field}" absolute form must use http: or https:`,
840
+ );
841
+ }
842
+ return v;
843
+ } catch (err) {
844
+ if (err instanceof ModuleManifestError) throw err;
845
+ throw new ModuleManifestError(
846
+ `${where}: "${field}" must be a relative path, a path starting with "/", or a full http(s) URL`,
847
+ );
457
848
  }
458
- return v;
459
- } catch (err) {
460
- if (err instanceof ModuleManifestError) throw err;
849
+ }
850
+ // Relative (per-instance, mount-joined) form. Forbid `..` traversal so a
851
+ // declared value can only deepen the module's own mount, never escape it.
852
+ if (v.split("/").some((segment) => segment === "..")) {
853
+ throw new ModuleManifestError(
854
+ `${where}: "${field}" relative form must not contain ".." segments`,
855
+ );
856
+ }
857
+ // Forbid backslashes anywhere in the relative form — the simplest closure
858
+ // of the backslash-traversal quirk: WHATWG URL parsing treats `\` as `/`
859
+ // in special (http/https) schemes, so `a\..\b` joined under a mount would
860
+ // normalize to `a/../b` and pop a segment, escaping the `..`-segment check
861
+ // above. Percent-encoded forms (`..%2f`) need no equivalent guard:
862
+ // `new URL()` does NOT decode percent-escapes during base-join, so a
863
+ // `..%2f` stays a literal three-char segment and never traverses (pinned
864
+ // in module-manifest.test.ts).
865
+ if (v.includes("\\")) {
461
866
  throw new ModuleManifestError(
462
- `${where}: "${field}" must be a path starting with "/" or a full http(s) URL`,
867
+ `${where}: "${field}" relative form must not contain backslashes`,
463
868
  );
464
869
  }
870
+ return v;
465
871
  }
466
872
 
467
873
  /**