@relayflows/sdk 2.0.24 → 2.0.26
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/dist/authored-flow-executor.d.ts +8 -0
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +40 -1
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts +15 -2
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +33 -4
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-hooks.d.ts +29 -0
- package/dist/authored-hooks.d.ts.map +1 -0
- package/dist/authored-hooks.js +117 -0
- package/dist/authored-hooks.js.map +1 -0
- package/dist/authored-node-entry.js +1 -0
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-root.d.ts +7 -0
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +14 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-source-authority.d.ts.map +1 -1
- package/dist/authored-source-authority.js +6 -0
- package/dist/authored-source-authority.js.map +1 -1
- package/dist/babysitter-catalog-export.d.ts +9 -0
- package/dist/babysitter-catalog-export.d.ts.map +1 -0
- package/dist/babysitter-catalog-export.js +36 -0
- package/dist/babysitter-catalog-export.js.map +1 -0
- package/dist/bundle-extensions.d.ts +19 -0
- package/dist/bundle-extensions.d.ts.map +1 -0
- package/dist/bundle-extensions.js +95 -0
- package/dist/bundle-extensions.js.map +1 -0
- package/dist/bundle-typescript.js +1 -1
- package/dist/bundle-typescript.js.map +1 -1
- package/dist/bundle.d.ts +11 -0
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +13 -4
- package/dist/bundle.js.map +1 -1
- package/dist/cli/add-extension.d.ts +28 -0
- package/dist/cli/add-extension.d.ts.map +1 -0
- package/dist/cli/add-extension.js +135 -0
- package/dist/cli/add-extension.js.map +1 -0
- package/dist/cli/add.d.ts +3 -0
- package/dist/cli/add.d.ts.map +1 -1
- package/dist/cli/add.js +8 -0
- package/dist/cli/add.js.map +1 -1
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +7 -2
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/check-helper-body.d.ts.map +1 -1
- package/dist/cli/check-helper-body.js +4 -0
- package/dist/cli/check-helper-body.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +25 -2
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check-typescript.d.ts.map +1 -1
- package/dist/cli/check-typescript.js +5 -1
- package/dist/cli/check-typescript.js.map +1 -1
- package/dist/cli/check.d.ts +24 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-deploy.d.ts +1 -0
- package/dist/cli/cloud-deploy.d.ts.map +1 -1
- package/dist/cli/cloud-deploy.js +11 -1
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/plugin.d.ts +50 -0
- package/dist/cli/plugin.d.ts.map +1 -0
- package/dist/cli/plugin.js +295 -0
- package/dist/cli/plugin.js.map +1 -0
- package/dist/cli-commands.d.ts +46 -3
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +32 -2
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -1
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +5 -0
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +7 -6
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-run.d.ts +2 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +14 -4
- package/dist/cloud-run.js.map +1 -1
- package/dist/failure-kinds.d.ts +2 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/flow-extension-compat.d.ts +18 -0
- package/dist/flow-extension-compat.d.ts.map +1 -0
- package/dist/flow-extension-compat.js +33 -0
- package/dist/flow-extension-compat.js.map +1 -0
- package/dist/flow-extension-loader.d.ts +92 -0
- package/dist/flow-extension-loader.d.ts.map +1 -0
- package/dist/flow-extension-loader.js +241 -0
- package/dist/flow-extension-loader.js.map +1 -0
- package/dist/flow-extension-manifest.d.ts +61 -0
- package/dist/flow-extension-manifest.d.ts.map +1 -0
- package/dist/flow-extension-manifest.js +188 -0
- package/dist/flow-extension-manifest.js.map +1 -0
- package/dist/flow-extension-submit.d.ts +38 -0
- package/dist/flow-extension-submit.d.ts.map +1 -0
- package/dist/flow-extension-submit.js +94 -0
- package/dist/flow-extension-submit.js.map +1 -0
- package/dist/flow-requirements.d.ts +13 -3
- package/dist/flow-requirements.d.ts.map +1 -1
- package/dist/flow-requirements.js +30 -0
- package/dist/flow-requirements.js.map +1 -1
- package/dist/plugin-github.d.ts +35 -0
- package/dist/plugin-github.d.ts.map +1 -0
- package/dist/plugin-github.js +92 -0
- package/dist/plugin-github.js.map +1 -0
- package/dist/plugin-loader.d.ts.map +1 -1
- package/dist/plugin-loader.js +5 -1
- package/dist/plugin-loader.js.map +1 -1
- package/dist/plugin-lock.d.ts +79 -0
- package/dist/plugin-lock.d.ts.map +1 -0
- package/dist/plugin-lock.js +203 -0
- package/dist/plugin-lock.js.map +1 -0
- package/dist/plugin-manifest.d.ts +5 -1
- package/dist/plugin-manifest.d.ts.map +1 -1
- package/dist/plugin-manifest.js +18 -0
- package/dist/plugin-manifest.js.map +1 -1
- package/dist/plugin-source.d.ts +38 -0
- package/dist/plugin-source.d.ts.map +1 -0
- package/dist/plugin-source.js +84 -0
- package/dist/plugin-source.js.map +1 -0
- package/dist/plugin-store.d.ts +30 -0
- package/dist/plugin-store.d.ts.map +1 -0
- package/dist/plugin-store.js +133 -0
- package/dist/plugin-store.js.map +1 -0
- package/dist/semver-range.d.ts +10 -0
- package/dist/semver-range.d.ts.map +1 -0
- package/dist/semver-range.js +88 -0
- package/dist/semver-range.js.map +1 -0
- package/package.json +9 -4
- package/src/authored-flow-executor.ts +60 -1
- package/src/authored-flow-loader.ts +49 -6
- package/src/authored-hooks.ts +136 -0
- package/src/authored-node-entry.ts +1 -0
- package/src/authored-root.ts +22 -1
- package/src/authored-source-authority.ts +6 -0
- package/src/babysitter-catalog-export.ts +44 -0
- package/src/bundle-extensions.ts +85 -0
- package/src/bundle-typescript.ts +1 -1
- package/src/bundle.ts +14 -4
- package/src/cli/add-extension.ts +134 -0
- package/src/cli/add.ts +9 -0
- package/src/cli/build.ts +7 -2
- package/src/cli/check-helper-body.ts +4 -0
- package/src/cli/check-triggers.ts +27 -2
- package/src/cli/check-typescript.ts +5 -1
- package/src/cli/check.ts +23 -0
- package/src/cli/cloud-deploy.ts +11 -1
- package/src/cli/plugin.ts +286 -0
- package/src/cli-commands.ts +32 -2
- package/src/cli.ts +23 -1
- package/src/cloud-deploy.ts +18 -8
- package/src/cloud-run.ts +16 -6
- package/src/flow-extension-compat.ts +35 -0
- package/src/flow-extension-loader.ts +304 -0
- package/src/flow-extension-manifest.ts +190 -0
- package/src/flow-extension-submit.ts +120 -0
- package/src/flow-requirements.ts +44 -3
- package/src/plugin-github.ts +98 -0
- package/src/plugin-loader.ts +5 -1
- package/src/plugin-lock.ts +194 -0
- package/src/plugin-manifest.ts +18 -0
- package/src/plugin-source.ts +91 -0
- package/src/plugin-store.ts +105 -0
- package/src/semver-range.ts +74 -0
package/src/cloud-run.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { CompileError, compileSpec, kernelToAuthoring, toKernelSpec } from './co
|
|
|
9
9
|
import type { FlowSpec } from './spec.js';
|
|
10
10
|
import { snapshotJsonValue, type JsonValue } from './json-value.js';
|
|
11
11
|
import { loadAuthoredFlow, type SurfaceModuleAuthority } from './authored-flow-loader.js';
|
|
12
|
+
import { assertNoUseDependencies, collectExtensionSubmissions, type FlowExtensionSubmission } from './flow-extension-submit.js';
|
|
12
13
|
import {
|
|
13
14
|
CloudFlowError, cloudConnection, cloudFetch, cloudRequest, cloudRunId, isCloudRecord,
|
|
14
15
|
type CloudConnectionOptions,
|
|
@@ -61,6 +62,7 @@ export function cloudSubmissionBody(submission: CloudSubmission): Record<string,
|
|
|
61
62
|
relayflowVersion: 'v2',
|
|
62
63
|
...(submission.authoredAuthority === undefined ? {} : { authoredAuthority: submission.authoredAuthority }),
|
|
63
64
|
...(submission.inputPresent ? { inputs: submission.inputs } : {}),
|
|
65
|
+
...(submission.extensions === undefined || submission.extensions.length === 0 ? {} : { extensions: submission.extensions }),
|
|
64
66
|
};
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -90,6 +92,7 @@ export interface CloudSubmission {
|
|
|
90
92
|
readonly name: string;
|
|
91
93
|
/** Authored `schedule.*` handlers, for `flows schedule` to pick up. */
|
|
92
94
|
readonly schedules: readonly ScheduleTriggerSource[];
|
|
95
|
+
readonly extensions?: readonly FlowExtensionSubmission[];
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
export async function prepareCloudSubmission(
|
|
@@ -97,7 +100,7 @@ export async function prepareCloudSubmission(
|
|
|
97
100
|
options: { input?: JsonValue; signal?: AbortSignal } = {},
|
|
98
101
|
): Promise<CloudSubmission> {
|
|
99
102
|
let spec: FlowSpec | undefined;
|
|
100
|
-
let authored: { source: string; authority: CloudAuthoredAuthority; name: string; schedules: ScheduleTriggerSource[] } | undefined;
|
|
103
|
+
let authored: { source: string; authority: CloudAuthoredAuthority; name: string; schedules: ScheduleTriggerSource[]; extensions: readonly FlowExtensionSubmission[] } | undefined;
|
|
101
104
|
const inputPresent = Object.prototype.hasOwnProperty.call(options, 'input');
|
|
102
105
|
let authoredInput: JsonValue | undefined;
|
|
103
106
|
try {
|
|
@@ -122,14 +125,13 @@ export async function prepareCloudSubmission(
|
|
|
122
125
|
`${flow.path} is not a loadable authored flow: ${error instanceof Error ? error.message : String(error)}. `
|
|
123
126
|
+ 'Run `flows check` on it from the same directory.');
|
|
124
127
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
'Cloud authored submission currently accepts one self-contained .flow.ts source without use dependencies.');
|
|
128
|
-
}
|
|
128
|
+
assertNoUseDependencies(loaded);
|
|
129
|
+
const extensions = await collectExtensionSubmissions(loaded);
|
|
129
130
|
authored = {
|
|
130
131
|
source,
|
|
131
132
|
name: definition.name,
|
|
132
133
|
schedules: definition.handlers.flatMap(h => h.trigger.kind === 'schedule' ? [h.trigger] : []),
|
|
134
|
+
extensions,
|
|
133
135
|
authority: Object.freeze({
|
|
134
136
|
schemaVersion: 1,
|
|
135
137
|
sourceSha256: createHash('sha256').update(bytes).digest('hex'),
|
|
@@ -182,7 +184,15 @@ export async function prepareCloudSubmission(
|
|
|
182
184
|
return {
|
|
183
185
|
workflow: authored.source, fileType: 'ts', authoredAuthority: authored.authority,
|
|
184
186
|
inputs: authoredInput, inputPresent: true, name: authored.name, schedules: authored.schedules,
|
|
185
|
-
|
|
187
|
+
...(authored.extensions.length === 0 ? {} : { extensions: authored.extensions }),
|
|
188
|
+
specHash: createHash('sha256').update(canonicalize({
|
|
189
|
+
authority: authored.authority, input: authoredInput,
|
|
190
|
+
...(authored.extensions.length === 0 ? {} : {
|
|
191
|
+
extensions: authored.extensions.map(extension => ({
|
|
192
|
+
name: extension.name, digest: extension.digest, ref: extension.ref,
|
|
193
|
+
})),
|
|
194
|
+
}),
|
|
195
|
+
})).digest('hex'),
|
|
186
196
|
};
|
|
187
197
|
}
|
|
188
198
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { FlowExtensionManifest } from './flow-extension-manifest.js';
|
|
3
|
+
import { PluginError } from './plugin-manifest.js';
|
|
4
|
+
import { satisfiesRange } from './semver-range.js';
|
|
5
|
+
|
|
6
|
+
export interface RuntimeVersions { readonly sdk: string; readonly surface: string }
|
|
7
|
+
|
|
8
|
+
/** The versions a plugin's `compat` is checked against: this SDK and the surface it pins. */
|
|
9
|
+
export function runtimeVersions(): RuntimeVersions {
|
|
10
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; dependencies: Record<string, string> };
|
|
11
|
+
return { sdk: pkg.version, surface: pkg.dependencies['@relayflows/surface']! };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `compat.surface` / `compat.sdk` against the runtime: a miss is a refusal, never a warning. */
|
|
15
|
+
export function assertCompatible(manifest: FlowExtensionManifest, versions: RuntimeVersions): void {
|
|
16
|
+
for (const [what, range, actual] of [['surface', manifest.compat.surface, versions.surface], ['sdk', manifest.compat.sdk, versions.sdk]] as const) {
|
|
17
|
+
if (!satisfiesRange(actual, range)) throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `compat.base` against the flow being extended. `FlowHeader.version` is
|
|
23
|
+
* optional: a base without it matches only `"*"`.
|
|
24
|
+
*/
|
|
25
|
+
export function assertBaseCompatible(manifest: FlowExtensionManifest, base: { readonly name: string; readonly version?: string }): void {
|
|
26
|
+
const entry = manifest.compat.base.find(b => b.name === base.name);
|
|
27
|
+
if (entry === undefined) {
|
|
28
|
+
throw new PluginError('plugin_incompatible', `${manifest.name} extends ${manifest.compat.base.map(b => b.name).join(', ')}, not "${base.name}".`);
|
|
29
|
+
}
|
|
30
|
+
if (base.version === undefined) {
|
|
31
|
+
if (entry.version !== '*') throw new PluginError('plugin_incompatible', `${manifest.name} requires ${base.name} ${entry.version}, but the base flow declares no version; only "*" can be satisfied.`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!satisfiesRange(base.version, entry.version)) throw new PluginError('plugin_incompatible', `${manifest.name} requires ${base.name} ${entry.version}; the base flow is ${base.version}.`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import type { Ctx } from '@relayflows/surface';
|
|
4
|
+
import type { AuthoredFlowDefinition, FlowHandle } from './authored-flow.js';
|
|
5
|
+
import { sha256 } from './bundle.js';
|
|
6
|
+
import { assertBaseCompatible, assertCompatible, runtimeVersions, type RuntimeVersions } from './flow-extension-compat.js';
|
|
7
|
+
import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js';
|
|
8
|
+
import { findPluginProject } from './plugin-loader.js';
|
|
9
|
+
import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock.js';
|
|
10
|
+
import { PluginError } from './plugin-manifest.js';
|
|
11
|
+
import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compose schema-2 flow extensions onto a base authored flow.
|
|
15
|
+
*
|
|
16
|
+
* Order of operations is the security argument, so it is fixed:
|
|
17
|
+
* 1. `flows.json.plugins` and `flows.lock.json` must agree (declaration ⇔
|
|
18
|
+
* provenance, same order);
|
|
19
|
+
* 2. the materialized store is re-hashed against the lock's digest and the
|
|
20
|
+
* manifest bytes against the lock's manifest hash — nothing under
|
|
21
|
+
* `.flows/plugins` is read as code before this passes;
|
|
22
|
+
* 3. the manifest is validated, its compat checked against the runtime and
|
|
23
|
+
* the base flow, and anything this slice does not compose (hooks, `use`,
|
|
24
|
+
* schedule triggers, non-provider webhooks, gates) is refused;
|
|
25
|
+
* 4. only then is the entry imported, and its handlers are checked against
|
|
26
|
+
* the manifest's declared triggers — an entry cannot subscribe to more
|
|
27
|
+
* than it declared.
|
|
28
|
+
* Extensions compose after the base, in lock order; nothing replaces,
|
|
29
|
+
* reorders, or widens a base handler.
|
|
30
|
+
*/
|
|
31
|
+
type TriggerHandler = AuthoredFlowDefinition['handlers'][number];
|
|
32
|
+
|
|
33
|
+
export type FlowHook = (f: Ctx, input: unknown) => Promise<boolean>;
|
|
34
|
+
|
|
35
|
+
export interface LoadedFlowExtension {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly version: string;
|
|
38
|
+
readonly ref: string;
|
|
39
|
+
readonly digest: string;
|
|
40
|
+
readonly directory: string;
|
|
41
|
+
readonly entryPath: string;
|
|
42
|
+
readonly manifest: FlowExtensionManifest;
|
|
43
|
+
readonly handle: FlowHandle;
|
|
44
|
+
/** Bound to the surface copy the entry itself imported; the base's accessor cannot see this handle's WeakMap entry. */
|
|
45
|
+
readonly getDefinition: ImportedFlow<unknown>['getDefinition'];
|
|
46
|
+
readonly handlers: readonly TriggerHandler[];
|
|
47
|
+
readonly hooks: Readonly<Record<string, FlowHook>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ImportedFlow<Authority> {
|
|
51
|
+
readonly handle: FlowHandle;
|
|
52
|
+
readonly getDefinition: <Input = unknown>(handle: FlowHandle) => AuthoredFlowDefinition<Input>;
|
|
53
|
+
readonly surfaceAuthority: Authority;
|
|
54
|
+
readonly hooks: Readonly<Record<string, FlowHook>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LoadFlowExtensionsOptions<Authority> {
|
|
58
|
+
readonly importFlow: (path: string) => Promise<ImportedFlow<Authority>>;
|
|
59
|
+
readonly sameAuthority: (a: Authority, b: Authority) => boolean;
|
|
60
|
+
readonly versions?: RuntimeVersions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EXTENSION_HEADER_FIELDS = new Set(['budget', 'tools']);
|
|
64
|
+
const WALLCLOCK_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const;
|
|
65
|
+
|
|
66
|
+
function wallclockMs(value: string): number | undefined {
|
|
67
|
+
const match = /^(\d+)(ms|s|m|h|d)$/.exec(value);
|
|
68
|
+
if (!match) return undefined;
|
|
69
|
+
const unit = match[2] as keyof typeof WALLCLOCK_MS;
|
|
70
|
+
return Number(match[1]) * WALLCLOCK_MS[unit];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Credentials and servers declared on a flow-extension, probed before the base body starts. */
|
|
74
|
+
export async function probeFlowExtension(manifest: FlowExtensionManifest, env: NodeJS.ProcessEnv = process.env): Promise<void> {
|
|
75
|
+
for (const credential of manifest.preflight.credentials) {
|
|
76
|
+
if (!env[credential]?.trim()) throw new PluginError('plugin_credential_missing', `${manifest.name} requires ${credential}.`);
|
|
77
|
+
}
|
|
78
|
+
for (const server of manifest.preflight.servers) {
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(server, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
|
|
81
|
+
await response.body?.cancel();
|
|
82
|
+
if (!response.ok) throw new Error('unsuccessful response');
|
|
83
|
+
} catch { throw new PluginError('plugin_server_unreachable', `${manifest.name} cannot reach ${server}.`); }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function unsupported(name: string, what: string): never {
|
|
88
|
+
throw new PluginError('plugin_unsupported', `${name}: ${what} is not composed by this release.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The `{provider, event, action?}` a provider subscription lowers to, or undefined for anything else. */
|
|
92
|
+
function subscriptionOf(handler: TriggerHandler): { provider: string; event: string; action?: string } | undefined {
|
|
93
|
+
const trigger = handler.trigger;
|
|
94
|
+
if (trigger.kind !== 'webhook' || trigger.filter === undefined) return undefined;
|
|
95
|
+
const { provider, type, payload } = trigger.filter as { provider?: unknown; type?: unknown; payload?: unknown };
|
|
96
|
+
if (typeof provider !== 'string' || provider !== trigger.name || typeof type !== 'string') return undefined;
|
|
97
|
+
const action = typeof payload === 'object' && payload !== null && !Array.isArray(payload) ? (payload as { action?: unknown }).action : undefined;
|
|
98
|
+
if (action !== undefined && typeof action !== 'string') return undefined;
|
|
99
|
+
return action === undefined ? { provider, event: type } : { provider, event: type, action };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Server-authenticated integration delivery metadata. This is executor
|
|
104
|
+
* authority, not authored input: a caller must derive it from its verified
|
|
105
|
+
* delivery record and pass it out of band. User-controlled `input.event`
|
|
106
|
+
* objects never become this value.
|
|
107
|
+
*/
|
|
108
|
+
const HOSTED_EXTENSION_DISPATCH_AUTHORITY = Symbol('hosted-extension-dispatch-authority');
|
|
109
|
+
|
|
110
|
+
export type HostedExtensionDispatch = {
|
|
111
|
+
readonly [HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true;
|
|
112
|
+
readonly provenance: 'integration-watch';
|
|
113
|
+
readonly provider: string;
|
|
114
|
+
readonly eventType: string;
|
|
115
|
+
readonly deliveryId: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string };
|
|
119
|
+
const DISPATCH_PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
120
|
+
const DISPATCH_EVENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
121
|
+
const DISPATCH_DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
122
|
+
|
|
123
|
+
function hostedEventIdentity(dispatch: unknown): HostedEventIdentity {
|
|
124
|
+
if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) {
|
|
125
|
+
throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.');
|
|
126
|
+
}
|
|
127
|
+
const { provenance, provider, eventType, deliveryId } = dispatch as Partial<HostedExtensionDispatch>;
|
|
128
|
+
if ((dispatch as Partial<HostedExtensionDispatch>)[HOSTED_EXTENSION_DISPATCH_AUTHORITY] !== true
|
|
129
|
+
|| provenance !== 'integration-watch'
|
|
130
|
+
|| typeof provider !== 'string' || !DISPATCH_PROVIDER.test(provider)
|
|
131
|
+
|| typeof deliveryId !== 'string' || !DISPATCH_DELIVERY.test(deliveryId)
|
|
132
|
+
|| typeof eventType !== 'string') {
|
|
133
|
+
throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.');
|
|
134
|
+
}
|
|
135
|
+
const parts = eventType.split('.');
|
|
136
|
+
if ((parts.length !== 1 && parts.length !== 2) || parts.some(part => !DISPATCH_EVENT.test(part))) {
|
|
137
|
+
throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON.stringify(eventType)} is malformed.`);
|
|
138
|
+
}
|
|
139
|
+
return parts.length === 1
|
|
140
|
+
? { provider, event: parts[0]! }
|
|
141
|
+
: { provider, event: parts[0]!, action: parts[1]! };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Brand metadata only after the host has authenticated the integration
|
|
146
|
+
* delivery. The symbol is deliberately not serializable, so copying a direct
|
|
147
|
+
* run's JSON into executor options cannot mint dispatch authority.
|
|
148
|
+
*/
|
|
149
|
+
export function hostedExtensionDispatchFromVerifiedDelivery(
|
|
150
|
+
delivery: Omit<HostedExtensionDispatch, typeof HOSTED_EXTENSION_DISPATCH_AUTHORITY | 'provenance'>,
|
|
151
|
+
): HostedExtensionDispatch {
|
|
152
|
+
const dispatch = Object.freeze({
|
|
153
|
+
[HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true as const,
|
|
154
|
+
provenance: 'integration-watch' as const,
|
|
155
|
+
...delivery,
|
|
156
|
+
});
|
|
157
|
+
hostedEventIdentity(dispatch);
|
|
158
|
+
return dispatch;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a server-authenticated delivery to one extension handler. Authored
|
|
163
|
+
* input is deliberately absent from this API: direct runs may contain any
|
|
164
|
+
* JSON shape and cannot opt themselves into extension execution. Overlapping
|
|
165
|
+
* subscriptions fail closed, including a generic event handler overlapping
|
|
166
|
+
* an action-specific handler.
|
|
167
|
+
*/
|
|
168
|
+
export function extensionHandlerForHostedDispatch(
|
|
169
|
+
dispatch: unknown,
|
|
170
|
+
extensions: readonly Pick<LoadedFlowExtension, 'name' | 'handlers'>[],
|
|
171
|
+
): { readonly extension: Pick<LoadedFlowExtension, 'name' | 'handlers'>; readonly handler: TriggerHandler } | undefined {
|
|
172
|
+
if (dispatch === undefined) return undefined;
|
|
173
|
+
const identity = hostedEventIdentity(dispatch);
|
|
174
|
+
const matches = extensions.flatMap(extension => extension.handlers.flatMap(handler => {
|
|
175
|
+
const subscription = subscriptionOf(handler);
|
|
176
|
+
if (subscription === undefined || subscription.provider !== identity.provider || subscription.event !== identity.event) return [];
|
|
177
|
+
if (subscription.action !== undefined && subscription.action !== identity.action) return [];
|
|
178
|
+
return [{ extension, handler }];
|
|
179
|
+
}));
|
|
180
|
+
if (matches.length > 1) {
|
|
181
|
+
throw new PluginError(
|
|
182
|
+
'plugin_event_ambiguous',
|
|
183
|
+
`Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${matches.map(match => match.extension.name).join(', ')}).`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return matches[0];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertDeclaredSubscription(name: string, manifest: FlowExtensionManifest, handler: TriggerHandler, index: number): void {
|
|
190
|
+
const subscription = subscriptionOf(handler);
|
|
191
|
+
if (handler.trigger.kind === 'schedule') unsupported(name, `handler ${index} (a schedule trigger)`);
|
|
192
|
+
if (subscription === undefined) {
|
|
193
|
+
throw new PluginError('plugin_manifest_invalid', `${name}: handler ${index} is not a provider subscription; extension handlers must be provider triggers declared in flows-plugin.json.`);
|
|
194
|
+
}
|
|
195
|
+
const declared = manifest.triggers.find(t => t.provider === subscription.provider && t.event === subscription.event);
|
|
196
|
+
const covered = declared !== undefined && (subscription.action === undefined ? declared.actions.length === 0 : declared.actions.includes(subscription.action));
|
|
197
|
+
if (!covered) {
|
|
198
|
+
const spelled = `${subscription.provider} ${subscription.event}${subscription.action === undefined ? '' : `.${subscription.action}`}`;
|
|
199
|
+
throw new PluginError('plugin_manifest_invalid', `${name}: handler ${index} subscribes to ${spelled}, which flows-plugin.json does not declare in triggers.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function loadOne<Authority>(
|
|
204
|
+
root: string, lock: PluginLockEntry, ref: string,
|
|
205
|
+
base: { readonly definition: AuthoredFlowDefinition; readonly surfaceAuthority: Authority },
|
|
206
|
+
options: LoadFlowExtensionsOptions<Authority>,
|
|
207
|
+
): Promise<LoadedFlowExtension> {
|
|
208
|
+
const directory = pluginStoreDirectory(root, lock.name, lock.digest);
|
|
209
|
+
await verifyStoredPlugin(directory, lock.digest);
|
|
210
|
+
const manifestBytes = readFileSync(join(directory, 'flows-plugin.json'));
|
|
211
|
+
if (sha256(manifestBytes) !== lock.manifestSha256) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json differs from the lockfile's manifest hash.`);
|
|
212
|
+
let input: unknown;
|
|
213
|
+
try { input = JSON.parse(manifestBytes.toString('utf8')); }
|
|
214
|
+
catch { throw new PluginError('plugin_manifest_invalid', `${ref}: flows-plugin.json is not valid JSON.`); }
|
|
215
|
+
const manifest = validateFlowExtensionManifest(input);
|
|
216
|
+
if (manifest.name !== lock.name || manifest.version !== lock.version) throw new PluginError('plugin_source_drift', `${ref}: manifest names ${manifest.name}@${manifest.version}, lockfile has ${lock.name}@${lock.version}.`);
|
|
217
|
+
assertCompatible(manifest, options.versions ?? runtimeVersions());
|
|
218
|
+
assertBaseCompatible(manifest, { name: base.definition.name, version: base.definition.header.version });
|
|
219
|
+
const baseBudget = base.definition.header.budget;
|
|
220
|
+
const ceiling = manifest.permissions.budget;
|
|
221
|
+
if (ceiling?.dollars !== undefined && typeof baseBudget === 'object' && baseBudget.dollars !== undefined && ceiling.dollars > baseBudget.dollars) {
|
|
222
|
+
throw new PluginError('plugin_incompatible', `${manifest.name} declares a $${ceiling.dollars} budget ceiling above the base flow's $${baseBudget.dollars}.`);
|
|
223
|
+
}
|
|
224
|
+
if (ceiling?.wallclock !== undefined && typeof baseBudget === 'object' && baseBudget.wallclock !== undefined) {
|
|
225
|
+
const pluginMs = wallclockMs(ceiling.wallclock);
|
|
226
|
+
const baseMs = wallclockMs(baseBudget.wallclock);
|
|
227
|
+
if (pluginMs !== undefined && baseMs !== undefined && pluginMs > baseMs) {
|
|
228
|
+
throw new PluginError('plugin_incompatible', `${manifest.name} declares a ${ceiling.wallclock} wallclock ceiling above the base flow's ${baseBudget.wallclock}.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const entryPath = join(directory, manifest.entry);
|
|
232
|
+
let imported: ImportedFlow<Authority>;
|
|
233
|
+
try { imported = await options.importFlow(entryPath); }
|
|
234
|
+
catch (error) { throw new PluginError('plugin_manifest_invalid', `${ref}: entry ${manifest.entry} did not load: ${error instanceof Error ? error.message : String(error)}`); }
|
|
235
|
+
if (!options.sameAuthority(imported.surfaceAuthority, base.surfaceAuthority)) {
|
|
236
|
+
throw new PluginError('plugin_incompatible', `${manifest.name}: entry resolves a different @relayflows/surface than the base flow.`);
|
|
237
|
+
}
|
|
238
|
+
const definition = imported.getDefinition(imported.handle);
|
|
239
|
+
const foreign = Object.keys(definition.header).filter(key => !EXTENSION_HEADER_FIELDS.has(key));
|
|
240
|
+
if (foreign.length > 0) unsupported(manifest.name, `entry header ${foreign.join(', ')}`);
|
|
241
|
+
if (manifest.extends.handlers && definition.handlers.length === 0) {
|
|
242
|
+
throw new PluginError('plugin_manifest_invalid', `${manifest.name}: extends.handlers is true but ${manifest.entry} declares no .on() handlers.`);
|
|
243
|
+
}
|
|
244
|
+
if (!manifest.extends.handlers && definition.handlers.length > 0) {
|
|
245
|
+
throw new PluginError('plugin_manifest_invalid', `${manifest.name}: ${manifest.entry} declares handlers but extends.handlers is false.`);
|
|
246
|
+
}
|
|
247
|
+
definition.handlers.forEach((handler, index) => assertDeclaredSubscription(manifest.name, manifest, handler, index));
|
|
248
|
+
const hooks = imported.hooks;
|
|
249
|
+
const exported = Object.keys(hooks).sort();
|
|
250
|
+
const declared = [...manifest.extends.hooks].sort();
|
|
251
|
+
if (exported.join('\0') !== declared.join('\0')) {
|
|
252
|
+
throw new PluginError('plugin_manifest_invalid', `${manifest.name}: extends.hooks [${manifest.extends.hooks.join(', ')}] does not match exported hooks [${exported.join(', ')}].`);
|
|
253
|
+
}
|
|
254
|
+
const baseHooks = base.definition.header.hooks ?? [];
|
|
255
|
+
for (const hook of manifest.extends.hooks) {
|
|
256
|
+
if (!baseHooks.includes(hook)) {
|
|
257
|
+
throw new PluginError('plugin_incompatible', `${manifest.name}: hook ${hook} is not declared by the base flow.`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return Object.freeze({
|
|
261
|
+
name: manifest.name, version: manifest.version, ref, digest: lock.digest, directory, entryPath, manifest,
|
|
262
|
+
handle: imported.handle, getDefinition: imported.getDefinition, handlers: Object.freeze([...definition.handlers]),
|
|
263
|
+
hooks,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function parseHooksExport(module: Record<string, unknown>, name: string): Readonly<Record<string, FlowHook>> {
|
|
268
|
+
const exported = module['hooks'];
|
|
269
|
+
if (exported === undefined) return Object.freeze({});
|
|
270
|
+
if (typeof exported !== 'object' || exported === null || Array.isArray(exported)) {
|
|
271
|
+
throw new PluginError('plugin_manifest_invalid', `${name}: hooks export must be a record of functions.`);
|
|
272
|
+
}
|
|
273
|
+
const hooks: Record<string, FlowHook> = {};
|
|
274
|
+
for (const [key, value] of Object.entries(exported)) {
|
|
275
|
+
if (typeof value !== 'function') {
|
|
276
|
+
throw new PluginError('plugin_manifest_invalid', `${name}: hooks.${key} is not a function.`);
|
|
277
|
+
}
|
|
278
|
+
hooks[key] = value as FlowHook;
|
|
279
|
+
}
|
|
280
|
+
return Object.freeze(hooks);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Extensions declared by the project that owns `flowPath`, verified and loaded in lock order; empty when none are declared. */
|
|
284
|
+
export async function loadFlowExtensions<Authority>(
|
|
285
|
+
flowPath: string,
|
|
286
|
+
base: { readonly definition: AuthoredFlowDefinition; readonly surfaceAuthority: Authority },
|
|
287
|
+
options: LoadFlowExtensionsOptions<Authority>,
|
|
288
|
+
): Promise<readonly LoadedFlowExtension[]> {
|
|
289
|
+
const root = findPluginProject(dirname(flowPath));
|
|
290
|
+
if (root === undefined) return Object.freeze([]);
|
|
291
|
+
const declared = reconcileDeclaredExtensions(root);
|
|
292
|
+
const loaded: LoadedFlowExtension[] = [];
|
|
293
|
+
for (const { ref, entry } of declared) loaded.push(await loadOne(root, entry, ref, base, options));
|
|
294
|
+
return Object.freeze(loaded);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The base definition with extension handlers appended in lock order; the base's own fields are untouched. */
|
|
298
|
+
export function composeDefinition<Input>(base: AuthoredFlowDefinition<Input>, extensions: readonly LoadedFlowExtension[]): AuthoredFlowDefinition<Input> {
|
|
299
|
+
if (extensions.length === 0) return base;
|
|
300
|
+
return Object.freeze({
|
|
301
|
+
...base,
|
|
302
|
+
handlers: Object.freeze([...base.handlers, ...extensions.flatMap(extension => extension.handlers)]),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Ajv } from 'ajv';
|
|
2
|
+
import { providerEventTypes } from '@relayflows/surface';
|
|
3
|
+
import { FLOW_HARNESSES } from './flow-requirements.js';
|
|
4
|
+
import { snapshotJsonValue } from './json-value.js';
|
|
5
|
+
import { PluginError, pluginKindOf } from './plugin-manifest.js';
|
|
6
|
+
import { safePath } from './bundle.js';
|
|
7
|
+
import { SHA, parsePluginSource, type PluginSourceInput } from './plugin-source.js';
|
|
8
|
+
import { isVersionRange, parseVersion } from './semver-range.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schema 2, `kind: "flow-extension"`: a plugin whose `entry` default-exports
|
|
12
|
+
* `flow()` and contributes handlers, hooks, and gates to a base flow. It is the
|
|
13
|
+
* same `flows-plugin.json` file and the same preflight covenant as a helper
|
|
14
|
+
* plugin (RFC-0001 decision 13); only the kind decides which validator reads it.
|
|
15
|
+
* Helper manifests (`kind` absent) never reach this module.
|
|
16
|
+
*
|
|
17
|
+
* This slice validates and records the declaration. Runtime composition is
|
|
18
|
+
* refused with `plugin_unsupported` (plugin-loader.ts) until the handlers slice.
|
|
19
|
+
*/
|
|
20
|
+
export interface FlowExtensionTrigger { readonly provider: string; readonly event: string; readonly actions: readonly string[] }
|
|
21
|
+
export interface FlowExtensionCompat {
|
|
22
|
+
readonly surface: string;
|
|
23
|
+
readonly sdk: string;
|
|
24
|
+
readonly base: readonly { readonly name: string; readonly version: string }[];
|
|
25
|
+
}
|
|
26
|
+
export interface FlowExtensionPermissions {
|
|
27
|
+
readonly integrations: readonly string[];
|
|
28
|
+
readonly harnesses: readonly string[];
|
|
29
|
+
readonly mcp: readonly string[];
|
|
30
|
+
/** Declared effect classes, shown for review; not enforced by this runtime (gate 8 / #442). */
|
|
31
|
+
readonly writes: readonly string[];
|
|
32
|
+
readonly budget?: { readonly dollars?: number; readonly wallclock?: string };
|
|
33
|
+
}
|
|
34
|
+
export interface FlowExtensionManifest {
|
|
35
|
+
readonly schema: 2;
|
|
36
|
+
readonly kind: 'flow-extension';
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly version: string;
|
|
39
|
+
readonly description?: string;
|
|
40
|
+
/** Authoring copies may omit it; `flows add` records the resolved origin in the lockfile regardless. */
|
|
41
|
+
readonly source?: PluginSourceInput & { readonly sha?: string };
|
|
42
|
+
readonly compat: FlowExtensionCompat;
|
|
43
|
+
readonly entry: string;
|
|
44
|
+
readonly extends: { readonly handlers: boolean; readonly hooks: readonly string[] };
|
|
45
|
+
readonly triggers: readonly FlowExtensionTrigger[];
|
|
46
|
+
readonly permissions: FlowExtensionPermissions;
|
|
47
|
+
readonly preflight: { readonly credentials: readonly string[]; readonly servers: readonly string[] };
|
|
48
|
+
readonly config?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TOP_LEVEL = new Set(['schema', 'kind', 'name', 'version', 'description', 'source', 'compat', 'entry', 'extends', 'triggers', 'gates', 'verbs', 'permissions', 'preflight', 'config']);
|
|
52
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
53
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
54
|
+
const PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
55
|
+
const WRITE_CLASS = /^[a-z0-9-]+(?::[a-z0-9_-]+)+$/;
|
|
56
|
+
const WALLCLOCK = /^\d+(?:ms|s|m|h|d)$/;
|
|
57
|
+
const MAX_DESCRIPTION = 500;
|
|
58
|
+
const MAX_LIST = 64;
|
|
59
|
+
|
|
60
|
+
const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
61
|
+
const invalid = (message: string): never => { throw new PluginError('plugin_manifest_invalid', message); };
|
|
62
|
+
|
|
63
|
+
function stringList(value: unknown, what: string, pattern: RegExp): readonly string[] {
|
|
64
|
+
if (!Array.isArray(value) || value.length > MAX_LIST) return invalid(`${what} must be a list of at most ${MAX_LIST} strings.`);
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
for (const entry of value) {
|
|
67
|
+
if (typeof entry !== 'string' || !pattern.test(entry)) return invalid(`${what} has an invalid entry ${JSON.stringify(entry)}.`);
|
|
68
|
+
if (out.includes(entry)) return invalid(`${what} lists ${entry} twice.`);
|
|
69
|
+
out.push(entry);
|
|
70
|
+
}
|
|
71
|
+
return Object.freeze(out);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function compat(value: unknown): FlowExtensionCompat {
|
|
75
|
+
if (!object(value) || Object.keys(value).some(k => !['surface', 'sdk', 'base'].includes(k))) return invalid('compat expects surface, sdk, and base.');
|
|
76
|
+
for (const key of ['surface', 'sdk'] as const) {
|
|
77
|
+
if (typeof value[key] !== 'string' || !isVersionRange(value[key])) return invalid(`compat.${key} must be a version range (*, x.y.z, ^x.y.z, ~x.y.z, >=x.y.z [<a.b.c]).`);
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(value.base) || value.base.length === 0 || value.base.length > MAX_LIST) return invalid('compat.base must name at least one base flow.');
|
|
80
|
+
const base = value.base.map(entry => {
|
|
81
|
+
if (!object(entry) || Object.keys(entry).some(k => !['name', 'version'].includes(k))
|
|
82
|
+
|| typeof entry.name !== 'string' || entry.name.trim().length === 0 || entry.name.length > 100
|
|
83
|
+
|| typeof entry.version !== 'string' || !isVersionRange(entry.version)) return invalid('compat.base entries are { name, version range }.');
|
|
84
|
+
return Object.freeze({ name: entry.name, version: entry.version });
|
|
85
|
+
});
|
|
86
|
+
if (new Set(base.map(b => b.name)).size !== base.length) return invalid('compat.base names a base flow twice.');
|
|
87
|
+
return Object.freeze({ surface: value.surface as string, sdk: value.sdk as string, base: Object.freeze(base) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function triggers(value: unknown): readonly FlowExtensionTrigger[] {
|
|
91
|
+
if (!Array.isArray(value) || value.length > MAX_LIST) return invalid('triggers must be a list.');
|
|
92
|
+
const registry = providerEventTypes as Readonly<Record<string, readonly string[]>>;
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
return Object.freeze(value.map(entry => {
|
|
95
|
+
if (!object(entry) || Object.keys(entry).some(k => !['provider', 'event', 'actions'].includes(k))
|
|
96
|
+
|| typeof entry.provider !== 'string' || !PROVIDER.test(entry.provider)
|
|
97
|
+
|| typeof entry.event !== 'string' || !IDENTIFIER.test(entry.event)) return invalid('triggers entries are { provider, event, actions }.');
|
|
98
|
+
const actions = stringList(entry.actions, `triggers ${entry.provider}.${entry.event} actions`, IDENTIFIER);
|
|
99
|
+
const known = registry[entry.provider];
|
|
100
|
+
// Fail where ingress would: an event the surface registry cannot lower is
|
|
101
|
+
// refused now, not after deployment on the first real delivery.
|
|
102
|
+
if (known === undefined) throw new PluginError('plugin_event_unroutable', `Trigger provider ${entry.provider} is not in the surface event registry.`);
|
|
103
|
+
const unroutable = (actions.length === 0 ? [entry.event] : actions.map(a => `${entry.event}.${a}`)).filter(type => !known.includes(type));
|
|
104
|
+
if (unroutable.length > 0) throw new PluginError('plugin_event_unroutable', `Trigger ${entry.provider} ${unroutable.join(', ')} is not in the surface event registry.`);
|
|
105
|
+
const key = `${entry.provider}:${entry.event}`;
|
|
106
|
+
if (seen.has(key)) return invalid(`Trigger ${key} is declared twice.`);
|
|
107
|
+
seen.add(key);
|
|
108
|
+
return Object.freeze({ provider: entry.provider, event: entry.event, actions });
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function permissions(value: unknown): FlowExtensionPermissions {
|
|
113
|
+
if (!object(value) || Object.keys(value).some(k => !['integrations', 'harnesses', 'mcp', 'writes', 'budget'].includes(k))) {
|
|
114
|
+
return invalid('permissions expects integrations, harnesses, mcp, writes, and optional budget.');
|
|
115
|
+
}
|
|
116
|
+
const harnesses = stringList(value.harnesses, 'permissions.harnesses', /^[a-z]+$/);
|
|
117
|
+
for (const harness of harnesses) if (!(FLOW_HARNESSES as readonly string[]).includes(harness)) return invalid(`permissions.harnesses: unknown harness ${harness}.`);
|
|
118
|
+
let budget: FlowExtensionPermissions['budget'];
|
|
119
|
+
if (value.budget !== undefined) {
|
|
120
|
+
if (!object(value.budget) || Object.keys(value.budget).some(k => !['dollars', 'wallclock'].includes(k))) return invalid('permissions.budget expects dollars and/or wallclock.');
|
|
121
|
+
if (value.budget.dollars !== undefined && (typeof value.budget.dollars !== 'number' || !(value.budget.dollars > 0) || !Number.isFinite(value.budget.dollars))) return invalid('permissions.budget.dollars must be a positive number.');
|
|
122
|
+
if (value.budget.wallclock !== undefined && (typeof value.budget.wallclock !== 'string' || !WALLCLOCK.test(value.budget.wallclock))) return invalid('permissions.budget.wallclock must be a duration such as 45m.');
|
|
123
|
+
budget = Object.freeze({ ...(value.budget.dollars === undefined ? {} : { dollars: value.budget.dollars }), ...(value.budget.wallclock === undefined ? {} : { wallclock: value.budget.wallclock }) });
|
|
124
|
+
}
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
integrations: stringList(value.integrations, 'permissions.integrations', PROVIDER),
|
|
127
|
+
harnesses,
|
|
128
|
+
mcp: stringList(value.mcp, 'permissions.mcp', /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/),
|
|
129
|
+
writes: stringList(value.writes, 'permissions.writes', WRITE_CLASS),
|
|
130
|
+
...(budget === undefined ? {} : { budget }),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function source(value: unknown): FlowExtensionManifest['source'] {
|
|
135
|
+
if (!object(value) || Object.keys(value).some(k => !['host', 'owner', 'repo', 'sha', 'path'].includes(k))
|
|
136
|
+
|| value.host !== 'github' || typeof value.owner !== 'string' || typeof value.repo !== 'string'
|
|
137
|
+
|| (value.path !== undefined && typeof value.path !== 'string')
|
|
138
|
+
|| (value.sha !== undefined && (typeof value.sha !== 'string' || !SHA.test(value.sha)))) {
|
|
139
|
+
return invalid('source expects { host: "github", owner, repo, path, sha? }.');
|
|
140
|
+
}
|
|
141
|
+
const path = (value.path as string | undefined) ?? '';
|
|
142
|
+
const parsed = parsePluginSource(`github:${value.owner}/${value.repo}@${(value.sha as string | undefined) ?? 'HEAD'}${path === '' ? '' : `#${path}`}`);
|
|
143
|
+
return Object.freeze({ ...parsed, ...(value.sha === undefined ? {} : { sha: value.sha as string }) });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function validateFlowExtensionManifest(input: unknown): FlowExtensionManifest {
|
|
147
|
+
let v: unknown;
|
|
148
|
+
try { v = snapshotJsonValue(input, 'plugin manifest'); }
|
|
149
|
+
catch { return invalid('Plugin manifest must be JSON data.'); }
|
|
150
|
+
if (!object(v)) return invalid('Expected a plugin manifest object.');
|
|
151
|
+
if (pluginKindOf(v) !== 'flow-extension') throw new PluginError('plugin_kind_invalid', 'Expected kind "flow-extension".');
|
|
152
|
+
if (v.schema !== 2) return invalid('A flow-extension manifest is schema 2.');
|
|
153
|
+
const unknown = Object.keys(v).filter(k => !TOP_LEVEL.has(k));
|
|
154
|
+
if (unknown.length > 0) return invalid(`Unknown manifest fields: ${unknown.join(', ')}.`);
|
|
155
|
+
if (!Object.hasOwn(v, 'preflight')) throw new PluginError('plugin_preflight_missing', 'Plugin must declare preflight.');
|
|
156
|
+
if (typeof v.name !== 'string' || !NAME.test(v.name) || v.name.startsWith('helper-')) return invalid('name must be lowercase kebab-case and must not start with helper-.');
|
|
157
|
+
if (typeof v.version !== 'string' || parseVersion(v.version) === undefined) return invalid('version must be semver x.y.z.');
|
|
158
|
+
if (v.description !== undefined && (typeof v.description !== 'string' || v.description.length > MAX_DESCRIPTION)) return invalid(`description must be a string of at most ${MAX_DESCRIPTION} characters.`);
|
|
159
|
+
if (typeof v.entry !== 'string' || !v.entry.endsWith('.flow.ts') || !safePath(v.entry)) return invalid('entry must be a plugin-relative .flow.ts path.');
|
|
160
|
+
if (!object(v.extends) || Object.keys(v.extends).some(k => !['handlers', 'hooks', 'verbs', 'gates'].includes(k)) || typeof v.extends.handlers !== 'boolean') return invalid('extends expects { handlers: boolean, hooks: [] }.');
|
|
161
|
+
const hooks = stringList(v.extends.hooks ?? [], 'extends.hooks', NAME);
|
|
162
|
+
for (const [field, at] of [['verbs', v.extends.verbs], ['gates', v.extends.gates], ['verbs', v.verbs], ['gates', v.gates]] as const) {
|
|
163
|
+
if (at !== undefined && (!Array.isArray(at) || at.length > 0)) return invalid(`A flow extension declares no ${field}; ship a helper plugin beside it.`);
|
|
164
|
+
}
|
|
165
|
+
if (!v.extends.handlers && hooks.length === 0) return invalid('A flow extension must contribute handlers or at least one hook.');
|
|
166
|
+
if (!object(v.preflight) || Object.keys(v.preflight).some(k => !['credentials', 'servers'].includes(k))) return invalid('Preflight requires credentials and servers arrays.');
|
|
167
|
+
const credentials = stringList(v.preflight.credentials, 'preflight.credentials', IDENTIFIER);
|
|
168
|
+
const servers = stringList(v.preflight.servers, 'preflight.servers', /^https?:\/\/\S+$/);
|
|
169
|
+
for (const server of servers) {
|
|
170
|
+
try { if (!['http:', 'https:'].includes(new URL(server).protocol)) return invalid('Servers must be HTTP(S) URLs.'); }
|
|
171
|
+
catch { return invalid('Servers must be HTTP(S) URLs.'); }
|
|
172
|
+
}
|
|
173
|
+
let config: Record<string, unknown> | undefined;
|
|
174
|
+
if (v.config !== undefined) {
|
|
175
|
+
if (!object(v.config)) return invalid('config must be a JSON Schema object.');
|
|
176
|
+
try { new Ajv({ strict: false }).compile(v.config); } catch { return invalid('config is not a valid JSON Schema.'); }
|
|
177
|
+
config = v.config;
|
|
178
|
+
}
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
schema: 2, kind: 'flow-extension', name: v.name, version: v.version,
|
|
181
|
+
...(v.description === undefined ? {} : { description: v.description }),
|
|
182
|
+
...(v.source === undefined ? {} : { source: source(v.source) }),
|
|
183
|
+
compat: compat(v.compat), entry: v.entry,
|
|
184
|
+
extends: Object.freeze({ handlers: v.extends.handlers, hooks }),
|
|
185
|
+
triggers: triggers(v.triggers ?? []),
|
|
186
|
+
permissions: permissions(v.permissions),
|
|
187
|
+
preflight: Object.freeze({ credentials, servers }),
|
|
188
|
+
...(config === undefined ? {} : { config }),
|
|
189
|
+
});
|
|
190
|
+
}
|