@relayflows/sdk 2.0.24 → 2.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/authored-flow-executor.d.ts +3 -0
  2. package/dist/authored-flow-executor.d.ts.map +1 -1
  3. package/dist/authored-flow-executor.js +29 -1
  4. package/dist/authored-flow-executor.js.map +1 -1
  5. package/dist/authored-flow-loader.d.ts +15 -2
  6. package/dist/authored-flow-loader.d.ts.map +1 -1
  7. package/dist/authored-flow-loader.js +33 -4
  8. package/dist/authored-flow-loader.js.map +1 -1
  9. package/dist/authored-hooks.d.ts +29 -0
  10. package/dist/authored-hooks.d.ts.map +1 -0
  11. package/dist/authored-hooks.js +117 -0
  12. package/dist/authored-hooks.js.map +1 -0
  13. package/dist/authored-node-entry.js +1 -0
  14. package/dist/authored-node-entry.js.map +1 -1
  15. package/dist/authored-root.d.ts +7 -0
  16. package/dist/authored-root.d.ts.map +1 -1
  17. package/dist/authored-root.js +14 -1
  18. package/dist/authored-root.js.map +1 -1
  19. package/dist/authored-source-authority.d.ts.map +1 -1
  20. package/dist/authored-source-authority.js +6 -0
  21. package/dist/authored-source-authority.js.map +1 -1
  22. package/dist/bundle-extensions.d.ts +19 -0
  23. package/dist/bundle-extensions.d.ts.map +1 -0
  24. package/dist/bundle-extensions.js +95 -0
  25. package/dist/bundle-extensions.js.map +1 -0
  26. package/dist/bundle-typescript.js +1 -1
  27. package/dist/bundle-typescript.js.map +1 -1
  28. package/dist/bundle.d.ts +11 -0
  29. package/dist/bundle.d.ts.map +1 -1
  30. package/dist/bundle.js +13 -4
  31. package/dist/bundle.js.map +1 -1
  32. package/dist/cli/add-extension.d.ts +28 -0
  33. package/dist/cli/add-extension.d.ts.map +1 -0
  34. package/dist/cli/add-extension.js +135 -0
  35. package/dist/cli/add-extension.js.map +1 -0
  36. package/dist/cli/add.d.ts +3 -0
  37. package/dist/cli/add.d.ts.map +1 -1
  38. package/dist/cli/add.js +8 -0
  39. package/dist/cli/add.js.map +1 -1
  40. package/dist/cli/build.d.ts.map +1 -1
  41. package/dist/cli/build.js +7 -2
  42. package/dist/cli/build.js.map +1 -1
  43. package/dist/cli/check-helper-body.d.ts.map +1 -1
  44. package/dist/cli/check-helper-body.js +4 -0
  45. package/dist/cli/check-helper-body.js.map +1 -1
  46. package/dist/cli/check-triggers.d.ts.map +1 -1
  47. package/dist/cli/check-triggers.js +25 -2
  48. package/dist/cli/check-triggers.js.map +1 -1
  49. package/dist/cli/check-typescript.d.ts.map +1 -1
  50. package/dist/cli/check-typescript.js +5 -1
  51. package/dist/cli/check-typescript.js.map +1 -1
  52. package/dist/cli/check.d.ts +24 -0
  53. package/dist/cli/check.d.ts.map +1 -1
  54. package/dist/cli/check.js.map +1 -1
  55. package/dist/cli/cloud-deploy.d.ts +1 -0
  56. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  57. package/dist/cli/cloud-deploy.js +11 -1
  58. package/dist/cli/cloud-deploy.js.map +1 -1
  59. package/dist/cli/plugin.d.ts +50 -0
  60. package/dist/cli/plugin.d.ts.map +1 -0
  61. package/dist/cli/plugin.js +295 -0
  62. package/dist/cli/plugin.js.map +1 -0
  63. package/dist/cli-commands.d.ts +46 -3
  64. package/dist/cli-commands.d.ts.map +1 -1
  65. package/dist/cli-commands.js +32 -2
  66. package/dist/cli-commands.js.map +1 -1
  67. package/dist/cli.d.ts +2 -1
  68. package/dist/cli.d.ts.map +1 -1
  69. package/dist/cli.js +26 -1
  70. package/dist/cli.js.map +1 -1
  71. package/dist/cloud-deploy.d.ts +5 -0
  72. package/dist/cloud-deploy.d.ts.map +1 -1
  73. package/dist/cloud-deploy.js +7 -6
  74. package/dist/cloud-deploy.js.map +1 -1
  75. package/dist/cloud-run.d.ts +2 -0
  76. package/dist/cloud-run.d.ts.map +1 -1
  77. package/dist/cloud-run.js +14 -4
  78. package/dist/cloud-run.js.map +1 -1
  79. package/dist/failure-kinds.d.ts +2 -2
  80. package/dist/failure-kinds.d.ts.map +1 -1
  81. package/dist/flow-extension-compat.d.ts +18 -0
  82. package/dist/flow-extension-compat.d.ts.map +1 -0
  83. package/dist/flow-extension-compat.js +33 -0
  84. package/dist/flow-extension-compat.js.map +1 -0
  85. package/dist/flow-extension-loader.d.ts +61 -0
  86. package/dist/flow-extension-loader.d.ts.map +1 -0
  87. package/dist/flow-extension-loader.js +173 -0
  88. package/dist/flow-extension-loader.js.map +1 -0
  89. package/dist/flow-extension-manifest.d.ts +61 -0
  90. package/dist/flow-extension-manifest.d.ts.map +1 -0
  91. package/dist/flow-extension-manifest.js +188 -0
  92. package/dist/flow-extension-manifest.js.map +1 -0
  93. package/dist/flow-extension-submit.d.ts +38 -0
  94. package/dist/flow-extension-submit.d.ts.map +1 -0
  95. package/dist/flow-extension-submit.js +94 -0
  96. package/dist/flow-extension-submit.js.map +1 -0
  97. package/dist/flow-requirements.d.ts +13 -3
  98. package/dist/flow-requirements.d.ts.map +1 -1
  99. package/dist/flow-requirements.js +30 -0
  100. package/dist/flow-requirements.js.map +1 -1
  101. package/dist/plugin-github.d.ts +35 -0
  102. package/dist/plugin-github.d.ts.map +1 -0
  103. package/dist/plugin-github.js +92 -0
  104. package/dist/plugin-github.js.map +1 -0
  105. package/dist/plugin-loader.d.ts.map +1 -1
  106. package/dist/plugin-loader.js +5 -1
  107. package/dist/plugin-loader.js.map +1 -1
  108. package/dist/plugin-lock.d.ts +79 -0
  109. package/dist/plugin-lock.d.ts.map +1 -0
  110. package/dist/plugin-lock.js +203 -0
  111. package/dist/plugin-lock.js.map +1 -0
  112. package/dist/plugin-manifest.d.ts +5 -1
  113. package/dist/plugin-manifest.d.ts.map +1 -1
  114. package/dist/plugin-manifest.js +17 -0
  115. package/dist/plugin-manifest.js.map +1 -1
  116. package/dist/plugin-source.d.ts +38 -0
  117. package/dist/plugin-source.d.ts.map +1 -0
  118. package/dist/plugin-source.js +84 -0
  119. package/dist/plugin-source.js.map +1 -0
  120. package/dist/plugin-store.d.ts +30 -0
  121. package/dist/plugin-store.d.ts.map +1 -0
  122. package/dist/plugin-store.js +133 -0
  123. package/dist/plugin-store.js.map +1 -0
  124. package/dist/semver-range.d.ts +10 -0
  125. package/dist/semver-range.d.ts.map +1 -0
  126. package/dist/semver-range.js +88 -0
  127. package/dist/semver-range.js.map +1 -0
  128. package/package.json +2 -2
  129. package/src/authored-flow-executor.ts +36 -1
  130. package/src/authored-flow-loader.ts +49 -6
  131. package/src/authored-hooks.ts +136 -0
  132. package/src/authored-node-entry.ts +1 -0
  133. package/src/authored-root.ts +22 -1
  134. package/src/authored-source-authority.ts +6 -0
  135. package/src/bundle-extensions.ts +85 -0
  136. package/src/bundle-typescript.ts +1 -1
  137. package/src/bundle.ts +14 -4
  138. package/src/cli/add-extension.ts +134 -0
  139. package/src/cli/add.ts +9 -0
  140. package/src/cli/build.ts +7 -2
  141. package/src/cli/check-helper-body.ts +4 -0
  142. package/src/cli/check-triggers.ts +27 -2
  143. package/src/cli/check-typescript.ts +5 -1
  144. package/src/cli/check.ts +23 -0
  145. package/src/cli/cloud-deploy.ts +11 -1
  146. package/src/cli/plugin.ts +286 -0
  147. package/src/cli-commands.ts +32 -2
  148. package/src/cli.ts +23 -1
  149. package/src/cloud-deploy.ts +18 -8
  150. package/src/cloud-run.ts +16 -6
  151. package/src/flow-extension-compat.ts +35 -0
  152. package/src/flow-extension-loader.ts +217 -0
  153. package/src/flow-extension-manifest.ts +190 -0
  154. package/src/flow-extension-submit.ts +120 -0
  155. package/src/flow-requirements.ts +44 -3
  156. package/src/plugin-github.ts +98 -0
  157. package/src/plugin-loader.ts +5 -1
  158. package/src/plugin-lock.ts +194 -0
  159. package/src/plugin-manifest.ts +17 -0
  160. package/src/plugin-source.ts +91 -0
  161. package/src/plugin-store.ts +105 -0
  162. package/src/semver-range.ts +74 -0
@@ -0,0 +1,217 @@
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
+ function assertDeclaredSubscription(name: string, manifest: FlowExtensionManifest, handler: TriggerHandler, index: number): void {
103
+ const subscription = subscriptionOf(handler);
104
+ if (handler.trigger.kind === 'schedule') unsupported(name, `handler ${index} (a schedule trigger)`);
105
+ if (subscription === undefined) {
106
+ 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.`);
107
+ }
108
+ const declared = manifest.triggers.find(t => t.provider === subscription.provider && t.event === subscription.event);
109
+ const covered = declared !== undefined && (subscription.action === undefined ? declared.actions.length === 0 : declared.actions.includes(subscription.action));
110
+ if (!covered) {
111
+ const spelled = `${subscription.provider} ${subscription.event}${subscription.action === undefined ? '' : `.${subscription.action}`}`;
112
+ throw new PluginError('plugin_manifest_invalid', `${name}: handler ${index} subscribes to ${spelled}, which flows-plugin.json does not declare in triggers.`);
113
+ }
114
+ }
115
+
116
+ async function loadOne<Authority>(
117
+ root: string, lock: PluginLockEntry, ref: string,
118
+ base: { readonly definition: AuthoredFlowDefinition; readonly surfaceAuthority: Authority },
119
+ options: LoadFlowExtensionsOptions<Authority>,
120
+ ): Promise<LoadedFlowExtension> {
121
+ const directory = pluginStoreDirectory(root, lock.name, lock.digest);
122
+ await verifyStoredPlugin(directory, lock.digest);
123
+ const manifestBytes = readFileSync(join(directory, 'flows-plugin.json'));
124
+ if (sha256(manifestBytes) !== lock.manifestSha256) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json differs from the lockfile's manifest hash.`);
125
+ let input: unknown;
126
+ try { input = JSON.parse(manifestBytes.toString('utf8')); }
127
+ catch { throw new PluginError('plugin_manifest_invalid', `${ref}: flows-plugin.json is not valid JSON.`); }
128
+ const manifest = validateFlowExtensionManifest(input);
129
+ 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}.`);
130
+ assertCompatible(manifest, options.versions ?? runtimeVersions());
131
+ assertBaseCompatible(manifest, { name: base.definition.name, version: base.definition.header.version });
132
+ const baseBudget = base.definition.header.budget;
133
+ const ceiling = manifest.permissions.budget;
134
+ if (ceiling?.dollars !== undefined && typeof baseBudget === 'object' && baseBudget.dollars !== undefined && ceiling.dollars > baseBudget.dollars) {
135
+ throw new PluginError('plugin_incompatible', `${manifest.name} declares a $${ceiling.dollars} budget ceiling above the base flow's $${baseBudget.dollars}.`);
136
+ }
137
+ if (ceiling?.wallclock !== undefined && typeof baseBudget === 'object' && baseBudget.wallclock !== undefined) {
138
+ const pluginMs = wallclockMs(ceiling.wallclock);
139
+ const baseMs = wallclockMs(baseBudget.wallclock);
140
+ if (pluginMs !== undefined && baseMs !== undefined && pluginMs > baseMs) {
141
+ throw new PluginError('plugin_incompatible', `${manifest.name} declares a ${ceiling.wallclock} wallclock ceiling above the base flow's ${baseBudget.wallclock}.`);
142
+ }
143
+ }
144
+ const entryPath = join(directory, manifest.entry);
145
+ let imported: ImportedFlow<Authority>;
146
+ try { imported = await options.importFlow(entryPath); }
147
+ catch (error) { throw new PluginError('plugin_manifest_invalid', `${ref}: entry ${manifest.entry} did not load: ${error instanceof Error ? error.message : String(error)}`); }
148
+ if (!options.sameAuthority(imported.surfaceAuthority, base.surfaceAuthority)) {
149
+ throw new PluginError('plugin_incompatible', `${manifest.name}: entry resolves a different @relayflows/surface than the base flow.`);
150
+ }
151
+ const definition = imported.getDefinition(imported.handle);
152
+ const foreign = Object.keys(definition.header).filter(key => !EXTENSION_HEADER_FIELDS.has(key));
153
+ if (foreign.length > 0) unsupported(manifest.name, `entry header ${foreign.join(', ')}`);
154
+ if (manifest.extends.handlers && definition.handlers.length === 0) {
155
+ throw new PluginError('plugin_manifest_invalid', `${manifest.name}: extends.handlers is true but ${manifest.entry} declares no .on() handlers.`);
156
+ }
157
+ if (!manifest.extends.handlers && definition.handlers.length > 0) {
158
+ throw new PluginError('plugin_manifest_invalid', `${manifest.name}: ${manifest.entry} declares handlers but extends.handlers is false.`);
159
+ }
160
+ definition.handlers.forEach((handler, index) => assertDeclaredSubscription(manifest.name, manifest, handler, index));
161
+ const hooks = imported.hooks;
162
+ const exported = Object.keys(hooks).sort();
163
+ const declared = [...manifest.extends.hooks].sort();
164
+ if (exported.join('\0') !== declared.join('\0')) {
165
+ throw new PluginError('plugin_manifest_invalid', `${manifest.name}: extends.hooks [${manifest.extends.hooks.join(', ')}] does not match exported hooks [${exported.join(', ')}].`);
166
+ }
167
+ const baseHooks = base.definition.header.hooks ?? [];
168
+ for (const hook of manifest.extends.hooks) {
169
+ if (!baseHooks.includes(hook)) {
170
+ throw new PluginError('plugin_incompatible', `${manifest.name}: hook ${hook} is not declared by the base flow.`);
171
+ }
172
+ }
173
+ return Object.freeze({
174
+ name: manifest.name, version: manifest.version, ref, digest: lock.digest, directory, entryPath, manifest,
175
+ handle: imported.handle, getDefinition: imported.getDefinition, handlers: Object.freeze([...definition.handlers]),
176
+ hooks,
177
+ });
178
+ }
179
+
180
+ export function parseHooksExport(module: Record<string, unknown>, name: string): Readonly<Record<string, FlowHook>> {
181
+ const exported = module['hooks'];
182
+ if (exported === undefined) return Object.freeze({});
183
+ if (typeof exported !== 'object' || exported === null || Array.isArray(exported)) {
184
+ throw new PluginError('plugin_manifest_invalid', `${name}: hooks export must be a record of functions.`);
185
+ }
186
+ const hooks: Record<string, FlowHook> = {};
187
+ for (const [key, value] of Object.entries(exported)) {
188
+ if (typeof value !== 'function') {
189
+ throw new PluginError('plugin_manifest_invalid', `${name}: hooks.${key} is not a function.`);
190
+ }
191
+ hooks[key] = value as FlowHook;
192
+ }
193
+ return Object.freeze(hooks);
194
+ }
195
+
196
+ /** Extensions declared by the project that owns `flowPath`, verified and loaded in lock order; empty when none are declared. */
197
+ export async function loadFlowExtensions<Authority>(
198
+ flowPath: string,
199
+ base: { readonly definition: AuthoredFlowDefinition; readonly surfaceAuthority: Authority },
200
+ options: LoadFlowExtensionsOptions<Authority>,
201
+ ): Promise<readonly LoadedFlowExtension[]> {
202
+ const root = findPluginProject(dirname(flowPath));
203
+ if (root === undefined) return Object.freeze([]);
204
+ const declared = reconcileDeclaredExtensions(root);
205
+ const loaded: LoadedFlowExtension[] = [];
206
+ for (const { ref, entry } of declared) loaded.push(await loadOne(root, entry, ref, base, options));
207
+ return Object.freeze(loaded);
208
+ }
209
+
210
+ /** The base definition with extension handlers appended in lock order; the base's own fields are untouched. */
211
+ export function composeDefinition<Input>(base: AuthoredFlowDefinition<Input>, extensions: readonly LoadedFlowExtension[]): AuthoredFlowDefinition<Input> {
212
+ if (extensions.length === 0) return base;
213
+ return Object.freeze({
214
+ ...base,
215
+ handlers: Object.freeze([...base.handlers, ...extensions.flatMap(extension => extension.handlers)]),
216
+ });
217
+ }
@@ -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
+ }
@@ -0,0 +1,120 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { sha256 } from './bundle.js';
4
+ import { CloudFlowError } from './cloud-http.js';
5
+ import { extensionManifestOf } from './cli/add-extension.js';
6
+ import { assertCompatible, runtimeVersions } from './flow-extension-compat.js';
7
+ import type { LoadedAuthoredFlow } from './authored-flow-loader.js';
8
+ import type { FlowExtensionManifest } from './flow-extension-manifest.js';
9
+ import { probeFlowExtension, type LoadedFlowExtension } from './flow-extension-loader.js';
10
+ import { fetchGithubPlugin, MAX_PLUGIN_TOTAL_BYTES, resolveGithubSha, type FetchLike } from './plugin-github.js';
11
+ import { PluginError } from './plugin-manifest.js';
12
+ import { canonicalPluginRef, parsePluginSource } from './plugin-source.js';
13
+ import { readStoredPluginFiles } from './plugin-store.js';
14
+
15
+ export const MAX_EXTENSIONS_BYTES = MAX_PLUGIN_TOTAL_BYTES;
16
+
17
+ export interface FlowExtensionFileSubmission {
18
+ readonly path: string;
19
+ readonly sha256: string;
20
+ readonly bytes: number;
21
+ readonly encoding: 'utf8' | 'base64';
22
+ readonly content: string;
23
+ }
24
+
25
+ export interface FlowExtensionSubmission {
26
+ readonly name: string;
27
+ readonly version: string;
28
+ readonly ref: string;
29
+ readonly digest: string;
30
+ readonly manifestSha256: string;
31
+ readonly manifest: FlowExtensionManifest;
32
+ readonly files: readonly FlowExtensionFileSubmission[];
33
+ }
34
+
35
+ function encodeFile(path: string, data: Uint8Array): FlowExtensionFileSubmission {
36
+ const text = Buffer.from(data).toString('utf8');
37
+ const utf8 = Buffer.from(text, 'utf8').equals(Buffer.from(data));
38
+ return {
39
+ path,
40
+ sha256: sha256(data),
41
+ bytes: data.length,
42
+ encoding: utf8 ? 'utf8' : 'base64',
43
+ content: utf8 ? text : Buffer.from(data).toString('base64'),
44
+ };
45
+ }
46
+
47
+ async function submissionFromStore(extension: LoadedFlowExtension): Promise<FlowExtensionSubmission> {
48
+ const stored = await readStoredPluginFiles(extension.directory, extension.digest);
49
+ const files = stored.filter(file => file.path !== 'manifest.json').map(file => encodeFile(file.path, file.data));
50
+ const manifestBytes = readFileSync(join(extension.directory, 'flows-plugin.json'));
51
+ return {
52
+ name: extension.name, version: extension.version, ref: extension.ref, digest: extension.digest,
53
+ manifestSha256: sha256(manifestBytes),
54
+ manifest: extension.manifest,
55
+ files,
56
+ };
57
+ }
58
+
59
+ /** Resolve a GitHub plugin reference without writing the working tree. */
60
+ export async function resolveExtensionSubmission(
61
+ input: string,
62
+ options: { fetch?: FetchLike; versions?: { sdk: string; surface: string } } = {},
63
+ ): Promise<FlowExtensionSubmission> {
64
+ try {
65
+ const source = await resolveGithubSha(parsePluginSource(input), options.fetch);
66
+ const fetched = await fetchGithubPlugin(source, options.fetch);
67
+ const { manifest, manifestSha256 } = extensionManifestOf(fetched);
68
+ assertCompatible(manifest, options.versions ?? runtimeVersions());
69
+ const files = fetched.files.map(file => encodeFile(file.path, file.data));
70
+ return {
71
+ name: manifest.name, version: manifest.version, ref: canonicalPluginRef(source),
72
+ digest: fetched.digest, manifestSha256, manifest, files,
73
+ };
74
+ } catch (error) {
75
+ if (error instanceof CloudFlowError) throw error;
76
+ if (error instanceof PluginError) throw new CloudFlowError('invalid_input', error.message);
77
+ throw new CloudFlowError('invalid_input', error instanceof Error ? error.message : String(error));
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Installed extensions plus optional send-only `--plugin` refs. Caps the
83
+ * extensions field at 2 MB separately from the 256 KB source cap.
84
+ */
85
+ export async function collectExtensionSubmissions(
86
+ loaded: LoadedAuthoredFlow,
87
+ extraRefs: readonly string[] = [],
88
+ options: { fetch?: FetchLike } = {},
89
+ ): Promise<readonly FlowExtensionSubmission[]> {
90
+ const submissions: FlowExtensionSubmission[] = [];
91
+ for (const extension of loaded.extensions) submissions.push(await submissionFromStore(extension));
92
+ const names = new Set(submissions.map(s => s.name));
93
+ for (const ref of extraRefs) {
94
+ const extra = await resolveExtensionSubmission(ref, options);
95
+ if (names.has(extra.name)) {
96
+ throw new CloudFlowError('invalid_input', `Plugin ${extra.name} is already in this project; omit --plugin or remove the installed copy.`);
97
+ }
98
+ names.add(extra.name);
99
+ submissions.push(extra);
100
+ }
101
+ const total = Buffer.byteLength(JSON.stringify(submissions), 'utf8');
102
+ if (total > MAX_EXTENSIONS_BYTES) {
103
+ throw new CloudFlowError('invalid_input', `Flow extensions exceed Cloud's ${MAX_EXTENSIONS_BYTES}-byte extensions cap.`);
104
+ }
105
+ try {
106
+ for (const submission of submissions) await probeFlowExtension(submission.manifest);
107
+ } catch (error) {
108
+ if (error instanceof PluginError) throw new CloudFlowError('invalid_input', error.message);
109
+ throw error;
110
+ }
111
+ return submissions;
112
+ }
113
+
114
+ /** Graph nodes besides the root must be the composed extensions, not `use:` children. */
115
+ export function assertNoUseDependencies(loaded: LoadedAuthoredFlow): void {
116
+ if (loaded.graph.length !== 1 + loaded.extensions.length) {
117
+ throw new CloudFlowError('unsupported_source',
118
+ 'Cloud deploys one self-contained .flow.ts source without use dependencies.');
119
+ }
120
+ }
@@ -26,9 +26,9 @@ export type FlowHarness = (typeof FLOW_HARNESSES)[number];
26
26
  export interface FlowIntegrationRequirement {
27
27
  /** Cloud integration provider id (`slack`, `github`, `linear`, …). */
28
28
  provider: string;
29
- /** `tools`: a header declaration; `source`: a trigger or deploy target; `helper`: body use without a flag, or a YAML helper step. */
30
- from: 'tools' | 'source' | 'helper' | 'human';
31
- /** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`, `f.human to`. */
29
+ /** Where this need was declared: a header/source/helper/human route, or an extension manifest. */
30
+ from: 'tools' | 'source' | 'helper' | 'human' | 'extension';
31
+ /** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`, or `plugin "name"`. */
32
32
  detail: string;
33
33
  }
34
34
 
@@ -56,6 +56,47 @@ export interface FlowRequirementsContext {
56
56
  projectCli?: string;
57
57
  }
58
58
 
59
+ export interface FlowExtensionRequirements {
60
+ readonly name: string;
61
+ readonly permissions: {
62
+ readonly integrations: readonly string[];
63
+ readonly harnesses: readonly string[];
64
+ readonly mcp: readonly string[];
65
+ };
66
+ }
67
+
68
+ /** Add manifest-only needs without replacing the base flow's first declaration. */
69
+ export function mergeFlowExtensionRequirements(
70
+ base: FlowRequirements,
71
+ extensions: readonly FlowExtensionRequirements[],
72
+ ): FlowRequirements {
73
+ const integrations = [...base.integrations];
74
+ const integrationNames = new Set(integrations.map(entry => entry.provider));
75
+ const harnessUses = [...base.harnessUses];
76
+ const harnessNames = new Set<string>(base.harnesses);
77
+ const mcp = [...base.mcp];
78
+ const mcpNames = new Set(mcp);
79
+
80
+ for (const extension of extensions) {
81
+ for (const provider of extension.permissions.integrations) {
82
+ if (integrationNames.has(provider)) continue;
83
+ integrationNames.add(provider);
84
+ integrations.push({ provider, from: 'extension', detail: `plugin "${extension.name}"` });
85
+ }
86
+ for (const harness of extension.permissions.harnesses) {
87
+ if (harnessNames.has(harness)) continue;
88
+ harnessNames.add(harness);
89
+ harnessUses.push({ harness: harness as FlowHarness, detail: `plugin "${extension.name}"` });
90
+ }
91
+ for (const server of extension.permissions.mcp) {
92
+ if (mcpNames.has(server)) continue;
93
+ mcpNames.add(server);
94
+ mcp.push(server);
95
+ }
96
+ }
97
+ return { integrations, harnesses: harnessUses.map(use => use.harness), harnessUses, mcp };
98
+ }
99
+
59
100
  /** The inert subset of an authored definition this module reads. */
60
101
  export interface RequirementsFlowDefinition {
61
102
  readonly header?: { readonly tools?: Readonly<Record<string, unknown>> };