@openparachute/hub 0.6.5-rc.8 → 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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- 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.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-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-server.ts +349 -59
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- 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/web/ui/dist/assets/index-C-XzMVqN.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/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 =
|
|
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
|
package/src/module-manifest.ts
CHANGED
|
@@ -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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* - A
|
|
107
|
-
*
|
|
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.
|
|
439
|
-
* follow the same shape per the module-json-extensibility
|
|
440
|
-
* factored so the next URL-shaped field doesn't have to
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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}"
|
|
867
|
+
`${where}: "${field}" relative form must not contain backslashes`,
|
|
463
868
|
);
|
|
464
869
|
}
|
|
870
|
+
return v;
|
|
465
871
|
}
|
|
466
872
|
|
|
467
873
|
/**
|