@openparachute/hub 0.6.5-rc.8 → 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.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  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-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -65,6 +65,170 @@ 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
+ * 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
+
173
+ /**
174
+ * One declared parameter of a {@link ConnectionTemplate} — the operator-chosen
175
+ * blank in the template (e.g. WHICH vault, the channel name).
176
+ */
177
+ export interface ConnectionTemplateParameter {
178
+ /** Parameter identifier within the template, e.g. `vault`, `channel`. */
179
+ readonly key: string;
180
+ /**
181
+ * Where the chosen value lands on the connection body, e.g. `source.vault`
182
+ * or `sink.params.channel`. Opaque to the hub at P1 — builder UIs interpret
183
+ * the two shapes above; anything else rides through for future targets.
184
+ */
185
+ readonly target: string;
186
+ /** Operator-facing label. */
187
+ readonly title?: string;
188
+ readonly description?: string;
189
+ /** Optional pre-fill example a builder UI may show for this parameter. */
190
+ readonly example?: string;
191
+ }
192
+
193
+ /**
194
+ * A connection PRESET a module declares in `module.json` (boundary D2).
195
+ *
196
+ * Two shapes ship today, discriminated by presence of `source` + `sink`:
197
+ * - **event→action preset** (channel's `link-to-vault`): `source` (a module
198
+ * event + optional filter) + `sink` (a module action). The hub's
199
+ * Connections builder offers these as one-click pre-fills.
200
+ * - **config link** (scribe's `link-to-vault`, `kind: "config"`): no
201
+ * `source`/`sink` — a module-owned config flow described by other fields
202
+ * (`provider`/`target`, which ride through `extra`-style and are NOT
203
+ * interpreted by the hub). These are consumed by the module's own UI,
204
+ * not the hub builder.
205
+ *
206
+ * Declaration-driven so the hub SPA never hardcodes a per-module preset (the
207
+ * charter's per-module-view test); the hub only round-trips these through
208
+ * `/api/connections/catalog` (event→action presets only).
209
+ */
210
+ export interface ConnectionTemplate {
211
+ /** Template identifier within the module, e.g. `link-to-vault`. */
212
+ readonly key: string;
213
+ /** Operator-facing label. */
214
+ readonly title: string;
215
+ readonly description?: string;
216
+ /** Provenance label for connections created from this template. */
217
+ readonly requestedBy?: string;
218
+ /** Optional discriminator — scribe ships `"config"`. Absent = event→action. */
219
+ readonly kind?: string;
220
+ /** The source event the template pre-fills (+ optional filter, opaque). */
221
+ readonly source?: {
222
+ readonly module: string;
223
+ readonly event: string;
224
+ readonly filter?: unknown;
225
+ };
226
+ /** The sink action the template pre-fills. */
227
+ readonly sink?: { readonly module: string; readonly action: string };
228
+ /** Operator-chosen blanks. */
229
+ readonly parameters?: readonly ConnectionTemplateParameter[];
230
+ }
231
+
68
232
  export interface ModuleManifest {
69
233
  /** Stable ecosystem identifier — `[a-z][a-z0-9-]*`, also the services.json key. */
70
234
  readonly name: string;
@@ -99,12 +263,18 @@ export interface ModuleManifest {
99
263
  * Where the module's admin UI lives. Hub renders a "Manage" link when set
100
264
  * (see `parachute-patterns/patterns/module-json-extensibility.md`).
101
265
  *
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.
266
+ * Three shapes (unified URL-resolution semantics — B4 of the 2026-06-09
267
+ * hub-module-boundary migration):
268
+ * - A full http(s) URL used verbatim. Escape hatch for modules whose
269
+ * admin UI is hosted off-origin.
270
+ * - A leading-`/` path (e.g. `"/scribe/admin"`) ORIGIN-ABSOLUTE, used
271
+ * verbatim against the hub origin.
272
+ * - A relative path (e.g. `"admin/"`) — the PER-INSTANCE form; hub joins
273
+ * it under the module's mounted URL: `<module-url>/<managementUrl>`.
274
+ *
275
+ * COMPAT SHIM (one release): the literal legacy `"/admin"`/`"/admin/"` on a
276
+ * vault entry is treated as the old per-instance relative form (mount-join)
277
+ * — deployed vaults still declare it until the vault wave ships.
108
278
  *
109
279
  * Absent = no link rendered (CLI-only management). Same back-compat rule
110
280
  * as `hasAuth` / `init` / `urlForEntry`.
@@ -140,6 +310,45 @@ export interface ModuleManifest {
140
310
  * per-module rationale.
141
311
  */
142
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;
323
+ /**
324
+ * Discovery tier (2026-06-09 modular-UI architecture). When a module
325
+ * declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
326
+ * falls back to `service-spec.focusForShort` (vault/scribe/hub/surface →
327
+ * `core`, everything else → `experimental`). **Show all; never hide** —
328
+ * `focus` only groups + de-emphasizes. Additive + back-compatible.
329
+ */
330
+ readonly focus?: ModuleFocus;
331
+ /**
332
+ * Where the module's OWN config/admin surface lives — the module renders it,
333
+ * the hub frames/links it (2026-06-09 modular-UI architecture, P3). Same
334
+ * path-or-absolute-URL shape as `managementUrl` (distinct field: `managementUrl`
335
+ * predates this; `configUiUrl` is the canonical config-surface declaration the
336
+ * config shell consumes). Optional + back-compatible.
337
+ */
338
+ readonly configUiUrl?: string;
339
+ /**
340
+ * Free-form capability hints for the config shell, e.g. `["config",
341
+ * "credentials", "logs"]`. Metadata only at P1 — the hub round-trips it.
342
+ */
343
+ readonly adminCapabilities?: readonly string[];
344
+ /** Events this module EMITS — Connections left-hand side (P5). */
345
+ readonly events?: readonly ModuleEvent[];
346
+ /** Actions this module ACCEPTS — Connections right-hand side (P5). */
347
+ readonly actions?: readonly ModuleAction[];
348
+ /** Connection presets this module declares — see {@link ConnectionTemplate}. */
349
+ readonly connectionTemplates?: readonly ConnectionTemplate[];
350
+ /** Standing vault credentials this module can hold — see {@link ModuleCredential} (H4). */
351
+ readonly credentials?: readonly ModuleCredential[];
143
352
  }
144
353
 
145
354
  export class ModuleManifestError extends Error {
@@ -395,6 +604,16 @@ export function validateModuleManifest(
395
604
  const configSchema = asConfigSchema(m.configSchema, where);
396
605
  const managementUrl = asManagementUrl(m.managementUrl, where);
397
606
  const uiUrl = asUiUrl(m.uiUrl, where);
607
+ const focus = asFocus(m.focus, where);
608
+ const configUiUrl = asPathOrUrl(m.configUiUrl, where, "configUiUrl");
609
+ const adminCapabilities =
610
+ m.adminCapabilities === undefined
611
+ ? undefined
612
+ : asStringArray(m.adminCapabilities, where, "adminCapabilities");
613
+ const events = asEvents(m.events, where);
614
+ const actions = asActions(m.actions, where, name);
615
+ const connectionTemplates = asConnectionTemplates(m.connectionTemplates, where);
616
+ const credentials = asCredentials(m.credentials, where);
398
617
  let stripPrefix: boolean | undefined;
399
618
  if (m.stripPrefix !== undefined) {
400
619
  if (typeof m.stripPrefix !== "boolean") {
@@ -402,6 +621,13 @@ export function validateModuleManifest(
402
621
  }
403
622
  stripPrefix = m.stripPrefix;
404
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
+ }
405
631
 
406
632
  const out: ModuleManifest = { name, manifestName, port, paths, health };
407
633
  if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
@@ -423,9 +649,252 @@ export function validateModuleManifest(
423
649
  if (stripPrefix !== undefined) {
424
650
  (out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
425
651
  }
652
+ if (websocket !== undefined) {
653
+ (out as { websocket?: boolean }).websocket = websocket;
654
+ }
655
+ if (focus !== undefined) (out as { focus?: ModuleFocus }).focus = focus;
656
+ if (configUiUrl !== undefined) (out as { configUiUrl?: string }).configUiUrl = configUiUrl;
657
+ if (adminCapabilities !== undefined) {
658
+ (out as { adminCapabilities?: readonly string[] }).adminCapabilities = adminCapabilities;
659
+ }
660
+ if (events !== undefined) (out as { events?: readonly ModuleEvent[] }).events = events;
661
+ if (actions !== undefined) (out as { actions?: readonly ModuleAction[] }).actions = actions;
662
+ if (connectionTemplates !== undefined) {
663
+ (out as { connectionTemplates?: readonly ConnectionTemplate[] }).connectionTemplates =
664
+ connectionTemplates;
665
+ }
666
+ if (credentials !== undefined) {
667
+ (out as { credentials?: readonly ModuleCredential[] }).credentials = credentials;
668
+ }
426
669
  return out;
427
670
  }
428
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
+
714
+ const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
715
+
716
+ function asFocus(v: unknown, where: string): ModuleFocus | undefined {
717
+ if (v === undefined) return undefined;
718
+ if (typeof v !== "string" || !MODULE_FOCUS_VALUES.has(v as ModuleFocus)) {
719
+ throw new ModuleManifestError(`${where}: "focus" must be "core" | "experimental" if present`);
720
+ }
721
+ return v as ModuleFocus;
722
+ }
723
+
724
+ function asEvents(v: unknown, where: string): readonly ModuleEvent[] | undefined {
725
+ if (v === undefined) return undefined;
726
+ if (!Array.isArray(v)) {
727
+ throw new ModuleManifestError(`${where}: "events" must be an array if present`);
728
+ }
729
+ return v.map((raw, i) => {
730
+ const at = `events[${i}]`;
731
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
732
+ throw new ModuleManifestError(`${where}: "${at}" must be an object`);
733
+ }
734
+ const e = raw as Record<string, unknown>;
735
+ const out: ModuleEvent = {
736
+ key: asString(e.key, where, `${at}.key`),
737
+ title: asString(e.title, where, `${at}.title`),
738
+ };
739
+ if (e.filterSchema !== undefined) {
740
+ (out as { filterSchema?: unknown }).filterSchema = e.filterSchema;
741
+ }
742
+ return out;
743
+ });
744
+ }
745
+
746
+ function asActions(
747
+ v: unknown,
748
+ where: string,
749
+ /** Declaring module's name — enforces the `action.scope` namespace rule. */
750
+ name: string,
751
+ ): readonly ModuleAction[] | undefined {
752
+ if (v === undefined) return undefined;
753
+ if (!Array.isArray(v)) {
754
+ throw new ModuleManifestError(`${where}: "actions" must be an array if present`);
755
+ }
756
+ return v.map((raw, i) => {
757
+ const at = `actions[${i}]`;
758
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
759
+ throw new ModuleManifestError(`${where}: "${at}" must be an object`);
760
+ }
761
+ const a = raw as Record<string, unknown>;
762
+ const out: ModuleAction = {
763
+ key: asString(a.key, where, `${at}.key`),
764
+ title: asString(a.title, where, `${at}.title`),
765
+ };
766
+ if (a.inputSchema !== undefined) (out as { inputSchema?: unknown }).inputSchema = a.inputSchema;
767
+ if (a.endpoint !== undefined) {
768
+ const ep = asString(a.endpoint, where, `${at}.endpoint`);
769
+ if (!ep.startsWith("/")) {
770
+ throw new ModuleManifestError(`${where}: "${at}.endpoint" must start with "/"`);
771
+ }
772
+ (out as { endpoint?: string }).endpoint = ep;
773
+ }
774
+ if (a.scope !== undefined) {
775
+ const scope = asString(a.scope, where, `${at}.scope`);
776
+ // Scope-namespace rule (mirrors `scopes.defines` above): an action's
777
+ // `scope` is minted by the hub into a 90-day webhook bearer presented to
778
+ // THIS module's own endpoint, which validates `aud:<name>` + a scope in
779
+ // its own namespace. A legitimate `action.scope` is therefore always in
780
+ // the declaring module's namespace (channel.message.deliver → channel:send).
781
+ // Enforcing `<ns> === name` blocks a malicious module declaring e.g.
782
+ // `vault:default:admin` and tricking the hub into minting a cross-module
783
+ // privilege-escalating token when an operator wires a Connection to it.
784
+ // Cross-module tokens a sink legitimately needs for OTHER purposes (e.g.
785
+ // channel's reply path needs `vault:write`) are minted separately by the
786
+ // engine, NOT declared here.
787
+ const colon = scope.indexOf(":");
788
+ if (colon <= 0) {
789
+ throw new ModuleManifestError(
790
+ `${where}: "${at}.scope" "${scope}" must be namespaced as "<name>:<verb>"`,
791
+ );
792
+ }
793
+ const ns = scope.slice(0, colon);
794
+ if (ns !== name) {
795
+ throw new ModuleManifestError(
796
+ `${where}: "${at}.scope" "${scope}" namespace "${ns}" does not match module name "${name}"`,
797
+ );
798
+ }
799
+ (out as { scope?: string }).scope = scope;
800
+ }
801
+ if (a.provision !== undefined) (out as { provision?: unknown }).provision = a.provision;
802
+ return out;
803
+ });
804
+ }
805
+
806
+ /**
807
+ * Validate the optional `connectionTemplates` declaration (boundary D2).
808
+ * Light-touch like `events`/`actions` at P1 — the hub only round-trips these
809
+ * to `/api/connections/catalog`; `filter` rides through opaque (it's the same
810
+ * shape the connection body's `source.filter` takes).
811
+ *
812
+ * `source`/`sink` are OPTIONAL: scribe ships a `kind: "config"` template with
813
+ * neither (a module-owned config flow, not an event→action preset) — a strict
814
+ * requirement here would make every real-manifest read throw for scribe.
815
+ * When present, their inner shapes are validated.
816
+ */
817
+ function asConnectionTemplates(
818
+ v: unknown,
819
+ where: string,
820
+ ): readonly ConnectionTemplate[] | undefined {
821
+ if (v === undefined) return undefined;
822
+ if (!Array.isArray(v)) {
823
+ throw new ModuleManifestError(`${where}: "connectionTemplates" must be an array if present`);
824
+ }
825
+ return v.map((raw, i) => {
826
+ const at = `connectionTemplates[${i}]`;
827
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
828
+ throw new ModuleManifestError(`${where}: "${at}" must be an object`);
829
+ }
830
+ const t = raw as Record<string, unknown>;
831
+ let outSource: NonNullable<ConnectionTemplate["source"]> | undefined;
832
+ if (t.source !== undefined) {
833
+ const source = t.source;
834
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
835
+ throw new ModuleManifestError(`${where}: "${at}.source" must be an object if present`);
836
+ }
837
+ const src = source as Record<string, unknown>;
838
+ outSource = {
839
+ module: asString(src.module, where, `${at}.source.module`),
840
+ event: asString(src.event, where, `${at}.source.event`),
841
+ ...(src.filter !== undefined ? { filter: src.filter } : {}),
842
+ };
843
+ }
844
+ let outSink: NonNullable<ConnectionTemplate["sink"]> | undefined;
845
+ if (t.sink !== undefined) {
846
+ const sink = t.sink;
847
+ if (!sink || typeof sink !== "object" || Array.isArray(sink)) {
848
+ throw new ModuleManifestError(`${where}: "${at}.sink" must be an object if present`);
849
+ }
850
+ const snk = sink as Record<string, unknown>;
851
+ outSink = {
852
+ module: asString(snk.module, where, `${at}.sink.module`),
853
+ action: asString(snk.action, where, `${at}.sink.action`),
854
+ };
855
+ }
856
+ const out: ConnectionTemplate = {
857
+ key: asString(t.key, where, `${at}.key`),
858
+ title: asString(t.title, where, `${at}.title`),
859
+ };
860
+ if (outSource !== undefined) {
861
+ (out as { source?: ConnectionTemplate["source"] }).source = outSource;
862
+ }
863
+ if (outSink !== undefined) (out as { sink?: ConnectionTemplate["sink"] }).sink = outSink;
864
+ const kind = asOptionalString(t.kind, where, `${at}.kind`);
865
+ if (kind !== undefined) (out as { kind?: string }).kind = kind;
866
+ const description = asOptionalString(t.description, where, `${at}.description`);
867
+ if (description !== undefined) (out as { description?: string }).description = description;
868
+ const requestedBy = asOptionalString(t.requestedBy, where, `${at}.requestedBy`);
869
+ if (requestedBy !== undefined) (out as { requestedBy?: string }).requestedBy = requestedBy;
870
+ if (t.parameters !== undefined) {
871
+ if (!Array.isArray(t.parameters)) {
872
+ throw new ModuleManifestError(`${where}: "${at}.parameters" must be an array if present`);
873
+ }
874
+ const parameters = t.parameters.map((p, j) => {
875
+ const pat = `${at}.parameters[${j}]`;
876
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
877
+ throw new ModuleManifestError(`${where}: "${pat}" must be an object`);
878
+ }
879
+ const pr = p as Record<string, unknown>;
880
+ const param: ConnectionTemplateParameter = {
881
+ key: asString(pr.key, where, `${pat}.key`),
882
+ target: asString(pr.target, where, `${pat}.target`),
883
+ };
884
+ const title = asOptionalString(pr.title, where, `${pat}.title`);
885
+ if (title !== undefined) (param as { title?: string }).title = title;
886
+ const pdesc = asOptionalString(pr.description, where, `${pat}.description`);
887
+ if (pdesc !== undefined) (param as { description?: string }).description = pdesc;
888
+ const example = asOptionalString(pr.example, where, `${pat}.example`);
889
+ if (example !== undefined) (param as { example?: string }).example = example;
890
+ return param;
891
+ });
892
+ (out as { parameters?: readonly ConnectionTemplateParameter[] }).parameters = parameters;
893
+ }
894
+ return out;
895
+ });
896
+ }
897
+
429
898
  function asManagementUrl(v: unknown, where: string): string | undefined {
430
899
  return asPathOrUrl(v, where, "managementUrl");
431
900
  }
@@ -435,33 +904,77 @@ function asUiUrl(v: unknown, where: string): string | undefined {
435
904
  }
436
905
 
437
906
  /**
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.
907
+ * Validate a "path or http(s) URL" field. `managementUrl`, `uiUrl`, and
908
+ * `configUiUrl` follow the same shape per the module-json-extensibility
909
+ * pattern doc; factored so the next URL-shaped field doesn't have to
910
+ * copy-paste.
911
+ *
912
+ * Three valid shapes (unified URL-resolution semantics, B4 of the 2026-06-09
913
+ * hub-module-boundary migration):
914
+ * - a full http(s) URL — resolvers use it verbatim;
915
+ * - an origin-absolute path starting with a single "/" — resolvers use it
916
+ * verbatim against the hub origin;
917
+ * - a RELATIVE path (no leading slash, e.g. `"admin/"`) — the per-instance
918
+ * form; resolvers join it under the module's mount. Constrained so it can
919
+ * only deepen its mount: no `..` segments, no URL scheme.
920
+ *
921
+ * Rejected: protocol-relative forms like `"//evil.com"` — they start with "/"
922
+ * but `new URL("//evil.com", base)` resolves to the foreign origin, which
923
+ * would let a malicious module render an off-origin tile and turn the
924
+ * discovery page into an open-redirect surface.
441
925
  */
442
926
  function asPathOrUrl(v: unknown, where: string, field: string): string | undefined {
443
927
  if (v === undefined) return undefined;
444
928
  if (typeof v !== "string" || v.length === 0) {
445
929
  throw new ModuleManifestError(`${where}: "${field}" must be a non-empty string if present`);
446
930
  }
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:`);
931
+ if (v.startsWith("//")) {
932
+ throw new ModuleManifestError(
933
+ `${where}: "${field}" must not be protocol-relative ("//..." resolves off-origin)`,
934
+ );
935
+ }
936
+ // Origin-absolute path — verbatim.
937
+ if (v.startsWith("/")) return v;
938
+ // Scheme-bearing string must be a well-formed http(s) URL. Anything else
939
+ // ("ftp://...", "javascript:...") is rejected rather than smuggled through
940
+ // as a "relative path".
941
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v)) {
942
+ try {
943
+ const u = new URL(v);
944
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
945
+ throw new ModuleManifestError(
946
+ `${where}: "${field}" absolute form must use http: or https:`,
947
+ );
948
+ }
949
+ return v;
950
+ } catch (err) {
951
+ if (err instanceof ModuleManifestError) throw err;
952
+ throw new ModuleManifestError(
953
+ `${where}: "${field}" must be a relative path, a path starting with "/", or a full http(s) URL`,
954
+ );
457
955
  }
458
- return v;
459
- } catch (err) {
460
- if (err instanceof ModuleManifestError) throw err;
956
+ }
957
+ // Relative (per-instance, mount-joined) form. Forbid `..` traversal so a
958
+ // declared value can only deepen the module's own mount, never escape it.
959
+ if (v.split("/").some((segment) => segment === "..")) {
461
960
  throw new ModuleManifestError(
462
- `${where}: "${field}" must be a path starting with "/" or a full http(s) URL`,
961
+ `${where}: "${field}" relative form must not contain ".." segments`,
463
962
  );
464
963
  }
964
+ // Forbid backslashes anywhere in the relative form — the simplest closure
965
+ // of the backslash-traversal quirk: WHATWG URL parsing treats `\` as `/`
966
+ // in special (http/https) schemes, so `a\..\b` joined under a mount would
967
+ // normalize to `a/../b` and pop a segment, escaping the `..`-segment check
968
+ // above. Percent-encoded forms (`..%2f`) need no equivalent guard:
969
+ // `new URL()` does NOT decode percent-escapes during base-join, so a
970
+ // `..%2f` stays a literal three-char segment and never traverses (pinned
971
+ // in module-manifest.test.ts).
972
+ if (v.includes("\\")) {
973
+ throw new ModuleManifestError(
974
+ `${where}: "${field}" relative form must not contain backslashes`,
975
+ );
976
+ }
977
+ return v;
465
978
  }
466
979
 
467
980
  /**