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.
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/adapters/adapter.d.ts +1 -0
- package/dist/adapters/adapter.js +1 -0
- package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
- package/dist/adapters/sheets/sheets-adapter.js +531 -0
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +397 -0
- package/dist/cli/env.d.ts +3 -0
- package/dist/cli/env.js +103 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +6 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +39 -0
- package/dist/cli/init.d.ts +10 -0
- package/dist/cli/init.js +72 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +212 -0
- package/dist/cli/runs.d.ts +2 -0
- package/dist/cli/runs.js +108 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +108 -0
- package/dist/cli/sync.d.ts +2 -0
- package/dist/cli/sync.js +84 -0
- package/dist/cli/watch.d.ts +2 -0
- package/dist/cli/watch.js +348 -0
- package/dist/core/condition.d.ts +25 -0
- package/dist/core/condition.js +66 -0
- package/dist/core/defaults.d.ts +3 -0
- package/dist/core/defaults.js +7 -0
- package/dist/core/engine.d.ts +50 -0
- package/dist/core/engine.js +234 -0
- package/dist/core/env.d.ts +13 -0
- package/dist/core/env.js +72 -0
- package/dist/core/exec.d.ts +24 -0
- package/dist/core/exec.js +134 -0
- package/dist/core/extractor.d.ts +10 -0
- package/dist/core/extractor.js +33 -0
- package/dist/core/http-client.d.ts +32 -0
- package/dist/core/http-client.js +161 -0
- package/dist/core/rate-limiter.d.ts +25 -0
- package/dist/core/rate-limiter.js +64 -0
- package/dist/core/reconcile.d.ts +24 -0
- package/dist/core/reconcile.js +192 -0
- package/dist/core/run-format.d.ts +39 -0
- package/dist/core/run-format.js +201 -0
- package/dist/core/run-state.d.ts +64 -0
- package/dist/core/run-state.js +141 -0
- package/dist/core/run-tracker.d.ts +15 -0
- package/dist/core/run-tracker.js +57 -0
- package/dist/core/safe-compare.d.ts +8 -0
- package/dist/core/safe-compare.js +19 -0
- package/dist/core/shell-escape.d.ts +7 -0
- package/dist/core/shell-escape.js +9 -0
- package/dist/core/tab-resolver.d.ts +17 -0
- package/dist/core/tab-resolver.js +44 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.js +82 -0
- package/dist/core/types.d.ts +105 -0
- package/dist/core/types.js +2 -0
- package/dist/core/url-guard.d.ts +21 -0
- package/dist/core/url-guard.js +184 -0
- package/dist/core/validator.d.ts +11 -0
- package/dist/core/validator.js +261 -0
- package/dist/core/waterfall.d.ts +26 -0
- package/dist/core/waterfall.js +55 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +943 -0
- 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,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
|
+
}
|