@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/module-manifest.ts
CHANGED
|
@@ -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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* - A
|
|
107
|
-
*
|
|
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.
|
|
439
|
-
* follow the same shape per the module-json-extensibility
|
|
440
|
-
* factored so the next URL-shaped field doesn't have to
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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}"
|
|
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
|
/**
|