rowbound 1.0.2

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/dist/adapters/adapter.d.ts +1 -0
  4. package/dist/adapters/adapter.js +1 -0
  5. package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
  6. package/dist/adapters/sheets/sheets-adapter.js +531 -0
  7. package/dist/cli/config.d.ts +2 -0
  8. package/dist/cli/config.js +397 -0
  9. package/dist/cli/env.d.ts +3 -0
  10. package/dist/cli/env.js +103 -0
  11. package/dist/cli/format.d.ts +5 -0
  12. package/dist/cli/format.js +6 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +39 -0
  15. package/dist/cli/init.d.ts +10 -0
  16. package/dist/cli/init.js +72 -0
  17. package/dist/cli/run.d.ts +2 -0
  18. package/dist/cli/run.js +212 -0
  19. package/dist/cli/runs.d.ts +2 -0
  20. package/dist/cli/runs.js +108 -0
  21. package/dist/cli/status.d.ts +2 -0
  22. package/dist/cli/status.js +108 -0
  23. package/dist/cli/sync.d.ts +2 -0
  24. package/dist/cli/sync.js +84 -0
  25. package/dist/cli/watch.d.ts +2 -0
  26. package/dist/cli/watch.js +348 -0
  27. package/dist/core/condition.d.ts +25 -0
  28. package/dist/core/condition.js +66 -0
  29. package/dist/core/defaults.d.ts +3 -0
  30. package/dist/core/defaults.js +7 -0
  31. package/dist/core/engine.d.ts +50 -0
  32. package/dist/core/engine.js +234 -0
  33. package/dist/core/env.d.ts +13 -0
  34. package/dist/core/env.js +72 -0
  35. package/dist/core/exec.d.ts +24 -0
  36. package/dist/core/exec.js +134 -0
  37. package/dist/core/extractor.d.ts +10 -0
  38. package/dist/core/extractor.js +33 -0
  39. package/dist/core/http-client.d.ts +32 -0
  40. package/dist/core/http-client.js +161 -0
  41. package/dist/core/rate-limiter.d.ts +25 -0
  42. package/dist/core/rate-limiter.js +64 -0
  43. package/dist/core/reconcile.d.ts +24 -0
  44. package/dist/core/reconcile.js +192 -0
  45. package/dist/core/run-format.d.ts +39 -0
  46. package/dist/core/run-format.js +201 -0
  47. package/dist/core/run-state.d.ts +64 -0
  48. package/dist/core/run-state.js +141 -0
  49. package/dist/core/run-tracker.d.ts +15 -0
  50. package/dist/core/run-tracker.js +57 -0
  51. package/dist/core/safe-compare.d.ts +8 -0
  52. package/dist/core/safe-compare.js +19 -0
  53. package/dist/core/shell-escape.d.ts +7 -0
  54. package/dist/core/shell-escape.js +9 -0
  55. package/dist/core/tab-resolver.d.ts +17 -0
  56. package/dist/core/tab-resolver.js +44 -0
  57. package/dist/core/template.d.ts +32 -0
  58. package/dist/core/template.js +82 -0
  59. package/dist/core/types.d.ts +105 -0
  60. package/dist/core/types.js +2 -0
  61. package/dist/core/url-guard.d.ts +21 -0
  62. package/dist/core/url-guard.js +184 -0
  63. package/dist/core/validator.d.ts +11 -0
  64. package/dist/core/validator.js +261 -0
  65. package/dist/core/waterfall.d.ts +26 -0
  66. package/dist/core/waterfall.js +55 -0
  67. package/dist/index.d.ts +15 -0
  68. package/dist/index.js +16 -0
  69. package/dist/mcp/server.d.ts +1 -0
  70. package/dist/mcp/server.js +943 -0
  71. package/package.json +67 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Resolve template strings like {{row.email}} and {{env.API_KEY}}.
3
+ * Missing variables resolve to empty string.
4
+ *
5
+ * When `onMissing` is provided, it is called for every variable that
6
+ * resolves to `undefined` in the given context.
7
+ */
8
+ export function resolveTemplate(template, context, onMissing) {
9
+ const TEMPLATE_REGEX = /\{\{(row|env)\.([^}]+)\}\}/g;
10
+ return template.replace(TEMPLATE_REGEX, (_match, source, key) => {
11
+ if (source === "row") {
12
+ const value = context.row[key];
13
+ if (value === undefined && onMissing) {
14
+ onMissing(source, key);
15
+ }
16
+ return value ?? "";
17
+ }
18
+ if (source === "env") {
19
+ const value = context.env[key];
20
+ if (value === undefined && onMissing) {
21
+ onMissing(source, key);
22
+ }
23
+ return value ?? "";
24
+ }
25
+ return "";
26
+ });
27
+ }
28
+ /**
29
+ * Resolve template strings with an escape function applied to each resolved value.
30
+ *
31
+ * Used for shell contexts where row/env values must be sanitized before
32
+ * interpolation (e.g., shell-escaping to prevent command injection).
33
+ * The escape function is applied to each resolved placeholder value,
34
+ * NOT to static parts of the template.
35
+ */
36
+ export function resolveTemplateEscaped(template, context, escapeFn, onMissing) {
37
+ const TEMPLATE_REGEX = /\{\{(row|env)\.([^}]+)\}\}/g;
38
+ return template.replace(TEMPLATE_REGEX, (_match, source, key) => {
39
+ let value;
40
+ if (source === "row") {
41
+ value = context.row[key];
42
+ }
43
+ else if (source === "env") {
44
+ value = context.env[key];
45
+ }
46
+ if (value === undefined && onMissing) {
47
+ onMissing(source, key);
48
+ }
49
+ return escapeFn(value ?? "");
50
+ });
51
+ }
52
+ /** Maximum recursion depth for resolveObject to prevent stack overflow. */
53
+ const MAX_RESOLVE_DEPTH = 20;
54
+ /**
55
+ * Recursively resolve templates in an object/array/string.
56
+ * - Strings: resolve template placeholders
57
+ * - Arrays: resolve each element
58
+ * - Objects: resolve each value (keys are not resolved)
59
+ * - Other types: pass through unchanged
60
+ */
61
+ export function resolveObject(obj, context, onMissing, depth = 0) {
62
+ if (depth > MAX_RESOLVE_DEPTH) {
63
+ throw new Error(`resolveObject exceeded maximum recursion depth of ${MAX_RESOLVE_DEPTH}`);
64
+ }
65
+ if (typeof obj === "string") {
66
+ return resolveTemplate(obj, context, onMissing);
67
+ }
68
+ if (Array.isArray(obj)) {
69
+ return obj.map((item) => resolveObject(item, context, onMissing, depth + 1));
70
+ }
71
+ if (obj !== null && typeof obj === "object") {
72
+ const result = {};
73
+ for (const [key, value] of Object.entries(obj)) {
74
+ if (key === "__proto__" || key === "constructor" || key === "prototype")
75
+ continue;
76
+ result[key] = resolveObject(value, context, onMissing, depth + 1);
77
+ }
78
+ return result;
79
+ }
80
+ // Numbers, booleans, null, undefined — pass through
81
+ return obj;
82
+ }
@@ -0,0 +1,105 @@
1
+ /** Reference to a Google Sheet */
2
+ export interface SheetRef {
3
+ spreadsheetId: string;
4
+ sheetName?: string;
5
+ }
6
+ /** A row of data: header name -> cell value */
7
+ export type Row = Record<string, string>;
8
+ /** Shared context passed to condition evaluation and template resolution */
9
+ export interface ExecutionContext {
10
+ row: Row;
11
+ env: Record<string, string>;
12
+ results?: Record<string, unknown>;
13
+ }
14
+ /** A single cell update to write back to the sheet */
15
+ export interface CellUpdate {
16
+ row: number;
17
+ column: string;
18
+ value: string;
19
+ }
20
+ /** Error handling config: maps error codes to actions */
21
+ export type OnErrorConfig = Record<string, string | {
22
+ write: string;
23
+ }>;
24
+ /** HTTP enrichment action */
25
+ export interface HttpAction {
26
+ id: string;
27
+ type: "http";
28
+ target: string;
29
+ when?: string;
30
+ method: string;
31
+ url: string;
32
+ headers?: Record<string, string>;
33
+ body?: unknown;
34
+ extract: string;
35
+ onError?: OnErrorConfig;
36
+ }
37
+ /** A single provider in a waterfall action */
38
+ export interface WaterfallProvider {
39
+ name: string;
40
+ method: string;
41
+ url: string;
42
+ headers?: Record<string, string>;
43
+ body?: unknown;
44
+ extract: string;
45
+ onError?: OnErrorConfig;
46
+ }
47
+ /** Waterfall action: tries providers in order until one succeeds */
48
+ export interface WaterfallAction {
49
+ id: string;
50
+ type: "waterfall";
51
+ target: string;
52
+ when?: string;
53
+ providers: WaterfallProvider[];
54
+ }
55
+ /** Transform action: computes a value from existing data */
56
+ export interface TransformAction {
57
+ id: string;
58
+ type: "transform";
59
+ target: string;
60
+ when?: string;
61
+ expression: string;
62
+ }
63
+ /** Exec action: runs a shell command and captures output */
64
+ export interface ExecAction {
65
+ id: string;
66
+ type: "exec";
67
+ target: string;
68
+ when?: string;
69
+ command: string;
70
+ extract?: string;
71
+ timeout?: number;
72
+ onError?: OnErrorConfig;
73
+ }
74
+ /** Union of all action types */
75
+ export type Action = HttpAction | WaterfallAction | TransformAction | ExecAction;
76
+ /** Global pipeline execution settings */
77
+ export interface PipelineSettings {
78
+ concurrency: number;
79
+ rateLimit: number;
80
+ retryAttempts: number;
81
+ retryBackoff: string;
82
+ }
83
+ /** Per-tab configuration (v2 multi-tab format) */
84
+ export interface TabConfig {
85
+ name: string;
86
+ columns: Record<string, string>;
87
+ actions: Action[];
88
+ }
89
+ /** Top-level pipeline configuration */
90
+ export interface PipelineConfig {
91
+ version: string;
92
+ tabs?: Record<string, TabConfig>;
93
+ columns?: Record<string, string>;
94
+ actions: Action[];
95
+ settings: PipelineSettings;
96
+ }
97
+ /** Adapter interface for reading/writing data (Google Sheets, future: Postgres, etc.) */
98
+ export interface Adapter {
99
+ readRows(ref: SheetRef): Promise<Row[]>;
100
+ writeCell(ref: SheetRef, update: CellUpdate): Promise<void>;
101
+ writeBatch(ref: SheetRef, updates: CellUpdate[]): Promise<void>;
102
+ readConfig(ref: SheetRef): Promise<PipelineConfig | null>;
103
+ writeConfig(ref: SheetRef, config: PipelineConfig): Promise<void>;
104
+ getHeaders(ref: SheetRef): Promise<string[]>;
105
+ }
@@ -0,0 +1,2 @@
1
+ // Rowbound Core Types
2
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * URL validation guard to prevent SSRF (Server-Side Request Forgery).
3
+ *
4
+ * Blocks requests to private/internal networks and non-HTTPS URLs
5
+ * unless explicitly allowed.
6
+ *
7
+ * Known limitation: DNS rebinding attacks are NOT mitigated here. A hostname
8
+ * could resolve to a public IP at check time and then re-resolve to a private
9
+ * IP when the actual HTTP request is made. Mitigating this would require
10
+ * resolving DNS before the check and pinning the IP for the request, which
11
+ * adds latency and complexity. For now, this is accepted as a known gap.
12
+ */
13
+ /**
14
+ * Validate a URL before making an HTTP request.
15
+ *
16
+ * - Blocks non-HTTPS URLs by default (http://localhost and http://127.0.0.1
17
+ * are allowed for dev; set ROWBOUND_ALLOW_HTTP=true to allow all HTTP)
18
+ * - Blocks private IP ranges to prevent SSRF to internal services
19
+ * - Throws on invalid URLs
20
+ */
21
+ export declare function validateUrl(url: string): void;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * URL validation guard to prevent SSRF (Server-Side Request Forgery).
3
+ *
4
+ * Blocks requests to private/internal networks and non-HTTPS URLs
5
+ * unless explicitly allowed.
6
+ *
7
+ * Known limitation: DNS rebinding attacks are NOT mitigated here. A hostname
8
+ * could resolve to a public IP at check time and then re-resolve to a private
9
+ * IP when the actual HTTP request is made. Mitigating this would require
10
+ * resolving DNS before the check and pinning the IP for the request, which
11
+ * adds latency and complexity. For now, this is accepted as a known gap.
12
+ */
13
+ /**
14
+ * Check if a hostname is an IPv6 private/reserved address.
15
+ *
16
+ * Blocked ranges:
17
+ * - ::1 (loopback)
18
+ * - fe80::/10 (link-local)
19
+ * - fc00::/7 (unique local — fc00::/8 + fd00::/8)
20
+ * - ::ffff:x.x.x.x (IPv4-mapped IPv6 — delegates to IPv4 private check)
21
+ */
22
+ function isPrivateIpv6(ip) {
23
+ // Normalize: strip surrounding brackets (URLs use [::1] form)
24
+ const raw = ip.replace(/^\[|\]$/g, "");
25
+ const lower = raw.toLowerCase();
26
+ // ::1 loopback
27
+ if (lower === "::1")
28
+ return true;
29
+ // fe80::/10 link-local
30
+ if (lower.startsWith("fe80:") || lower.startsWith("fe80%"))
31
+ return true;
32
+ // fc00::/7 — matches fc and fd prefixes
33
+ if (lower.startsWith("fc") || lower.startsWith("fd"))
34
+ return true;
35
+ // ::ffff: IPv4-mapped IPv6 — two forms:
36
+ // 1. Dotted-quad: ::ffff:10.0.0.1
37
+ // 2. Hex-pair (URL-normalized): ::ffff:a00:1 (which is ::ffff:0a00:0001)
38
+ const v4MappedDotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
39
+ if (v4MappedDotted) {
40
+ return isPrivateIpv4(v4MappedDotted[1]);
41
+ }
42
+ // Hex-pair form: ::ffff:XXYY:ZZWW where XXYY and ZZWW are hex
43
+ const v4MappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
44
+ if (v4MappedHex) {
45
+ const hi = parseInt(v4MappedHex[1], 16);
46
+ const lo = parseInt(v4MappedHex[2], 16);
47
+ const a = (hi >>> 8) & 0xff;
48
+ const b = hi & 0xff;
49
+ const c = (lo >>> 8) & 0xff;
50
+ const d = lo & 0xff;
51
+ return isPrivateIpv4(`${a}.${b}.${c}.${d}`);
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * Check if a string is a numeric (decimal) IP representation.
57
+ * e.g. 2130706433 === 127.0.0.1, 0x7f000001, 0177.0.0.1
58
+ *
59
+ * Returns the dotted-quad string if it is, or null if not.
60
+ */
61
+ function decodeNumericIp(hostname) {
62
+ // Decimal integer form: e.g. 2130706433
63
+ if (/^\d+$/.test(hostname)) {
64
+ const num = Number(hostname);
65
+ if (num >= 0 && num <= 0xffffffff) {
66
+ const a = (num >>> 24) & 0xff;
67
+ const b = (num >>> 16) & 0xff;
68
+ const c = (num >>> 8) & 0xff;
69
+ const d = num & 0xff;
70
+ return `${a}.${b}.${c}.${d}`;
71
+ }
72
+ }
73
+ // Hex form: 0x7f000001
74
+ if (/^0x[0-9a-fA-F]+$/.test(hostname)) {
75
+ const num = Number(hostname);
76
+ if (num >= 0 && num <= 0xffffffff) {
77
+ const a = (num >>> 24) & 0xff;
78
+ const b = (num >>> 16) & 0xff;
79
+ const c = (num >>> 8) & 0xff;
80
+ const d = num & 0xff;
81
+ return `${a}.${b}.${c}.${d}`;
82
+ }
83
+ }
84
+ // Octal dotted form: 0177.0.0.1
85
+ if (/^0\d*(\.\d+){0,3}$/.test(hostname)) {
86
+ const parts = hostname.split(".").map((p) => parseInt(p, 8));
87
+ if (parts.length === 4 &&
88
+ parts.every((p) => !Number.isNaN(p) && p >= 0 && p <= 255)) {
89
+ return parts.join(".");
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Check if an IPv4 address falls within a private/reserved range.
96
+ *
97
+ * Blocked ranges:
98
+ * - 0.0.0.0 (unspecified)
99
+ * - 10.0.0.0/8 (private)
100
+ * - 127.0.0.0/8 (loopback)
101
+ * - 172.16.0.0/12 (private)
102
+ * - 192.168.0.0/16 (private)
103
+ * - 169.254.0.0/16 (link-local / cloud metadata)
104
+ */
105
+ function isPrivateIpv4(ip) {
106
+ const parts = ip.split(".").map(Number);
107
+ if (parts.length !== 4 ||
108
+ parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {
109
+ return false;
110
+ }
111
+ const [a, b] = parts;
112
+ // 0.0.0.0
113
+ if (a === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0)
114
+ return true;
115
+ // 10.0.0.0/8
116
+ if (a === 10)
117
+ return true;
118
+ // 127.0.0.0/8 (full loopback range)
119
+ if (a === 127)
120
+ return true;
121
+ // 172.16.0.0/12
122
+ if (a === 172 && b >= 16 && b <= 31)
123
+ return true;
124
+ // 192.168.0.0/16
125
+ if (a === 192 && b === 168)
126
+ return true;
127
+ // 169.254.0.0/16 (link-local, AWS metadata endpoint)
128
+ if (a === 169 && b === 254)
129
+ return true;
130
+ return false;
131
+ }
132
+ /**
133
+ * Check if a hostname resolves to a private/reserved IP.
134
+ * Handles IPv4, IPv6, numeric/octal representations.
135
+ */
136
+ function isPrivateIp(hostname) {
137
+ // Check numeric/octal IP representations first
138
+ const decoded = decodeNumericIp(hostname);
139
+ if (decoded) {
140
+ return isPrivateIpv4(decoded);
141
+ }
142
+ // IPv6 check (URL class strips brackets, but handle both)
143
+ if (hostname.includes(":")) {
144
+ return isPrivateIpv6(hostname);
145
+ }
146
+ // Standard IPv4 dotted-quad
147
+ return isPrivateIpv4(hostname);
148
+ }
149
+ /**
150
+ * Validate a URL before making an HTTP request.
151
+ *
152
+ * - Blocks non-HTTPS URLs by default (http://localhost and http://127.0.0.1
153
+ * are allowed for dev; set ROWBOUND_ALLOW_HTTP=true to allow all HTTP)
154
+ * - Blocks private IP ranges to prevent SSRF to internal services
155
+ * - Throws on invalid URLs
156
+ */
157
+ export function validateUrl(url) {
158
+ let parsed;
159
+ try {
160
+ parsed = new URL(url);
161
+ }
162
+ catch {
163
+ throw new Error(`Invalid URL: ${url}`);
164
+ }
165
+ const allowHttp = process.env.ROWBOUND_ALLOW_HTTP === "true";
166
+ const hostname = parsed.hostname;
167
+ // Localhost dev exception: http://localhost and http://127.0.0.1 are allowed
168
+ const isLocalhostDev = hostname === "localhost" || hostname === "127.0.0.1";
169
+ // Protocol check
170
+ if (parsed.protocol === "http:") {
171
+ if (!isLocalhostDev && !allowHttp) {
172
+ throw new Error(`Non-HTTPS URL blocked: ${url}. Set ROWBOUND_ALLOW_HTTP=true to allow HTTP.`);
173
+ }
174
+ }
175
+ else if (parsed.protocol !== "https:") {
176
+ throw new Error(`Unsupported protocol: ${parsed.protocol}`);
177
+ }
178
+ // Private IP check — block even for HTTPS (DNS rebinding protection).
179
+ // Skip for explicit localhost dev addresses (127.0.0.1 / localhost) so
180
+ // local development still works.
181
+ if (!isLocalhostDev && isPrivateIp(hostname)) {
182
+ throw new Error(`URL blocked: ${hostname} is a private IP address.`);
183
+ }
184
+ }
@@ -0,0 +1,11 @@
1
+ import type { PipelineConfig } from "./types.js";
2
+ /** Result of validating a PipelineConfig. */
3
+ export interface ValidationResult {
4
+ valid: boolean;
5
+ errors: string[];
6
+ warnings: string[];
7
+ }
8
+ /**
9
+ * Validate an entire PipelineConfig, returning errors and warnings.
10
+ */
11
+ export declare function validateConfig(config: PipelineConfig): ValidationResult;
@@ -0,0 +1,261 @@
1
+ import vm from "node:vm";
2
+ import { JSONPath } from "jsonpath-plus";
3
+ /** Maximum recommended config size in bytes (Developer Metadata limit is 30K). */
4
+ const CONFIG_SIZE_WARN = 25_000;
5
+ /** Allowed action types. */
6
+ const VALID_ACTION_TYPES = new Set(["http", "waterfall", "transform", "exec"]);
7
+ /** Known retry backoff strategies. */
8
+ const KNOWN_BACKOFF = new Set(["exponential", "linear", "fixed"]);
9
+ /** Standard HTTP methods. */
10
+ const KNOWN_HTTP_METHODS = new Set([
11
+ "GET",
12
+ "POST",
13
+ "PUT",
14
+ "PATCH",
15
+ "DELETE",
16
+ "HEAD",
17
+ "OPTIONS",
18
+ ]);
19
+ /**
20
+ * Regex for valid template placeholders: {{row.xxx}} or {{env.XXX}}.
21
+ * Invalid patterns are anything inside {{ }} that does NOT match this form.
22
+ */
23
+ const VALID_TEMPLATE_REGEX = /^\{\{(row|env)\.[^}]+\}\}$/;
24
+ /**
25
+ * Finds all {{...}} patterns in a string and returns any that are invalid.
26
+ */
27
+ function findInvalidTemplates(value) {
28
+ const TEMPLATE_REGEX = /\{\{[^}]*\}\}/g;
29
+ const invalid = [];
30
+ for (const match of value.matchAll(TEMPLATE_REGEX)) {
31
+ if (!VALID_TEMPLATE_REGEX.test(match[0])) {
32
+ invalid.push(match[0]);
33
+ }
34
+ }
35
+ return invalid;
36
+ }
37
+ /**
38
+ * Recursively collect all string values from a JSON-like structure.
39
+ */
40
+ function collectStrings(obj) {
41
+ if (typeof obj === "string")
42
+ return [obj];
43
+ if (Array.isArray(obj))
44
+ return obj.flatMap(collectStrings);
45
+ if (obj !== null && typeof obj === "object") {
46
+ return Object.values(obj).flatMap(collectStrings);
47
+ }
48
+ return [];
49
+ }
50
+ /**
51
+ * Check whether a `when` expression can be parsed as valid JavaScript.
52
+ * Uses vm.compileFunction which only compiles (no callable Function object).
53
+ */
54
+ function isParseableExpression(expression) {
55
+ try {
56
+ vm.compileFunction(`"use strict"; return (${expression});`);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /**
64
+ * Check whether a JSONPath expression is syntactically valid.
65
+ */
66
+ function isValidJsonPath(expression) {
67
+ try {
68
+ JSONPath({ path: expression, json: {}, eval: false });
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /**
76
+ * Validate template strings in url, headers, and body of an action-like object.
77
+ */
78
+ function validateTemplates(label, obj, errors) {
79
+ // Check url
80
+ if (obj.url) {
81
+ const invalid = findInvalidTemplates(obj.url);
82
+ for (const t of invalid) {
83
+ errors.push(`${label}: invalid template "${t}" in url — must be {{row.x}} or {{env.X}}`);
84
+ }
85
+ }
86
+ // Check header values
87
+ if (obj.headers) {
88
+ for (const [headerKey, headerVal] of Object.entries(obj.headers)) {
89
+ const invalid = findInvalidTemplates(headerVal);
90
+ for (const t of invalid) {
91
+ errors.push(`${label}: invalid template "${t}" in header "${headerKey}"`);
92
+ }
93
+ }
94
+ }
95
+ // Check body (recursively collect strings)
96
+ if (obj.body !== undefined) {
97
+ const bodyStrings = collectStrings(obj.body);
98
+ for (const s of bodyStrings) {
99
+ const invalid = findInvalidTemplates(s);
100
+ for (const t of invalid) {
101
+ errors.push(`${label}: invalid template "${t}" in body`);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Validate an entire PipelineConfig, returning errors and warnings.
108
+ */
109
+ export function validateConfig(config) {
110
+ const errors = [];
111
+ const warnings = [];
112
+ // 1. Version check
113
+ if (config.version !== "1" && config.version !== "2") {
114
+ errors.push(`Invalid version "${config.version}" (expected "1" or "2")`);
115
+ }
116
+ // 2. Unique action IDs
117
+ const ids = config.actions.map((s) => s.id);
118
+ const seen = new Set();
119
+ const duplicates = new Set();
120
+ for (const id of ids) {
121
+ if (seen.has(id)) {
122
+ duplicates.add(id);
123
+ }
124
+ seen.add(id);
125
+ }
126
+ if (duplicates.size > 0) {
127
+ errors.push(`Duplicate action IDs: ${[...duplicates].join(", ")}`);
128
+ }
129
+ // 3 & 4. Per-action validation
130
+ for (const action of config.actions) {
131
+ const label = `Action "${action.id}"`;
132
+ // Common required fields
133
+ if (!action.id) {
134
+ errors.push("An action is missing the 'id' field");
135
+ }
136
+ if (!action.type) {
137
+ errors.push(`${label}: missing 'type' field`);
138
+ }
139
+ if (!action.target) {
140
+ errors.push(`${label}: missing 'target' field`);
141
+ }
142
+ // Valid action type
143
+ if (action.type && !VALID_ACTION_TYPES.has(action.type)) {
144
+ errors.push(`${label}: invalid type "${action.type}"`);
145
+ }
146
+ // 6. Parseable conditions
147
+ if (action.when !== undefined) {
148
+ if (!isParseableExpression(action.when)) {
149
+ errors.push(`${label}: 'when' expression has invalid syntax: "${action.when}"`);
150
+ }
151
+ }
152
+ // Type-specific validation
153
+ if (action.type === "http") {
154
+ const httpAction = action;
155
+ if (!httpAction.method) {
156
+ errors.push(`${label}: http action missing 'method'`);
157
+ }
158
+ else if (!KNOWN_HTTP_METHODS.has(httpAction.method.toUpperCase())) {
159
+ warnings.push(`${label}: HTTP method "${httpAction.method}" is not a standard method (expected one of: ${[...KNOWN_HTTP_METHODS].join(", ")})`);
160
+ }
161
+ if (!httpAction.url) {
162
+ errors.push(`${label}: http action missing 'url'`);
163
+ }
164
+ if (!httpAction.extract) {
165
+ errors.push(`${label}: http action missing 'extract'`);
166
+ }
167
+ // 5. Template validation
168
+ validateTemplates(label, httpAction, errors);
169
+ // 7. JSONPath validation
170
+ if (httpAction.extract && !isValidJsonPath(httpAction.extract)) {
171
+ errors.push(`${label}: invalid JSONPath in 'extract': "${httpAction.extract}"`);
172
+ }
173
+ }
174
+ else if (action.type === "waterfall") {
175
+ const waterfallAction = action;
176
+ if (!waterfallAction.providers ||
177
+ !Array.isArray(waterfallAction.providers) ||
178
+ waterfallAction.providers.length === 0) {
179
+ errors.push(`${label}: waterfall action must have a non-empty 'providers' array`);
180
+ }
181
+ else {
182
+ for (let i = 0; i < waterfallAction.providers.length; i++) {
183
+ const provider = waterfallAction.providers[i];
184
+ const pLabel = `${label} provider[${i}]`;
185
+ if (!provider.name) {
186
+ errors.push(`${pLabel}: missing 'name'`);
187
+ }
188
+ if (!provider.method) {
189
+ errors.push(`${pLabel}: missing 'method'`);
190
+ }
191
+ if (!provider.url) {
192
+ errors.push(`${pLabel}: missing 'url'`);
193
+ }
194
+ if (!provider.extract) {
195
+ errors.push(`${pLabel}: missing 'extract'`);
196
+ }
197
+ // Template validation on each provider
198
+ validateTemplates(provider.name ? `${label} provider "${provider.name}"` : pLabel, provider, errors);
199
+ // JSONPath validation on each provider
200
+ if (provider.extract && !isValidJsonPath(provider.extract)) {
201
+ errors.push(`${provider.name ? `${label} provider "${provider.name}"` : pLabel}: invalid JSONPath in 'extract': "${provider.extract}"`);
202
+ }
203
+ }
204
+ }
205
+ }
206
+ else if (action.type === "transform") {
207
+ const transformAction = action;
208
+ if (!transformAction.expression) {
209
+ errors.push(`${label}: transform action missing 'expression'`);
210
+ }
211
+ }
212
+ else if (action.type === "exec") {
213
+ const execAction = action;
214
+ if (!execAction.command) {
215
+ errors.push(`${label}: exec action missing 'command'`);
216
+ }
217
+ // Template validation in command
218
+ if (execAction.command) {
219
+ const invalid = findInvalidTemplates(execAction.command);
220
+ for (const t of invalid) {
221
+ errors.push(`${label}: invalid template "${t}" in command — must be {{row.x}} or {{env.X}}`);
222
+ }
223
+ }
224
+ // Validate timeout if present
225
+ if (execAction.timeout !== undefined &&
226
+ (typeof execAction.timeout !== "number" || execAction.timeout <= 0)) {
227
+ errors.push(`${label}: exec action 'timeout' must be a positive number (got ${JSON.stringify(execAction.timeout)})`);
228
+ }
229
+ // JSONPath validation on extract if present
230
+ if (execAction.extract && !isValidJsonPath(execAction.extract)) {
231
+ errors.push(`${label}: invalid JSONPath in 'extract': "${execAction.extract}"`);
232
+ }
233
+ }
234
+ }
235
+ // 8. Config size warning
236
+ const serialized = JSON.stringify(config);
237
+ if (serialized.length > CONFIG_SIZE_WARN) {
238
+ warnings.push(`Config size is ${serialized.length} bytes — approaching the 30K Developer Metadata limit`);
239
+ }
240
+ // 9. Settings validation
241
+ if (config.settings) {
242
+ const { concurrency, rateLimit, retryAttempts, retryBackoff } = config.settings;
243
+ if (typeof concurrency !== "number" || concurrency <= 0) {
244
+ errors.push(`settings.concurrency must be > 0 (got ${JSON.stringify(concurrency)})`);
245
+ }
246
+ if (typeof rateLimit !== "number" || rateLimit < 0) {
247
+ errors.push(`settings.rateLimit must be >= 0 (got ${JSON.stringify(rateLimit)})`);
248
+ }
249
+ if (typeof retryAttempts !== "number" || retryAttempts < 0) {
250
+ errors.push(`settings.retryAttempts must be >= 0 (got ${JSON.stringify(retryAttempts)})`);
251
+ }
252
+ if (typeof retryBackoff === "string" && !KNOWN_BACKOFF.has(retryBackoff)) {
253
+ warnings.push(`settings.retryBackoff "${retryBackoff}" is not a known strategy (known: ${[...KNOWN_BACKOFF].join(", ")})`);
254
+ }
255
+ }
256
+ return {
257
+ valid: errors.length === 0,
258
+ errors,
259
+ warnings,
260
+ };
261
+ }