reqon-dsl 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +23 -3
  2. package/dist/ast/nodes.d.ts +8 -0
  3. package/dist/auth/circuit-breaker.d.ts +11 -0
  4. package/dist/auth/circuit-breaker.js +83 -12
  5. package/dist/auth/credentials.d.ts +6 -1
  6. package/dist/auth/credentials.js +12 -4
  7. package/dist/auth/oauth2-provider.js +13 -3
  8. package/dist/auth/rate-limiter.d.ts +8 -1
  9. package/dist/auth/rate-limiter.js +30 -10
  10. package/dist/auth/token-store.js +8 -1
  11. package/dist/cli.d.ts +11 -1
  12. package/dist/cli.js +65 -6
  13. package/dist/config/constants.d.ts +15 -4
  14. package/dist/config/constants.js +15 -4
  15. package/dist/control/server.d.ts +17 -0
  16. package/dist/control/server.js +82 -5
  17. package/dist/control/types.d.ts +6 -0
  18. package/dist/debug/cli-debugger.js +8 -3
  19. package/dist/execution/store.js +2 -2
  20. package/dist/execution-log/events.d.ts +125 -0
  21. package/dist/execution-log/events.js +17 -0
  22. package/dist/execution-log/fold.d.ts +38 -0
  23. package/dist/execution-log/fold.js +54 -0
  24. package/dist/execution-log/index.d.ts +18 -0
  25. package/dist/execution-log/index.js +6 -0
  26. package/dist/execution-log/postgres-store.d.ts +36 -0
  27. package/dist/execution-log/postgres-store.js +108 -0
  28. package/dist/execution-log/resume.d.ts +11 -0
  29. package/dist/execution-log/resume.js +5 -0
  30. package/dist/execution-log/sqlite-store.d.ts +16 -0
  31. package/dist/execution-log/sqlite-store.js +101 -0
  32. package/dist/execution-log/store.d.ts +72 -0
  33. package/dist/execution-log/store.js +182 -0
  34. package/dist/index.d.ts +4 -3
  35. package/dist/index.js +4 -3
  36. package/dist/interpreter/context.d.ts +15 -0
  37. package/dist/interpreter/context.js +3 -0
  38. package/dist/interpreter/evaluator.js +38 -8
  39. package/dist/interpreter/executor.d.ts +63 -1
  40. package/dist/interpreter/executor.js +406 -30
  41. package/dist/interpreter/fetch-handler.d.ts +39 -1
  42. package/dist/interpreter/fetch-handler.js +84 -15
  43. package/dist/interpreter/http.d.ts +31 -2
  44. package/dist/interpreter/http.js +187 -26
  45. package/dist/interpreter/index.d.ts +3 -3
  46. package/dist/interpreter/index.js +3 -3
  47. package/dist/interpreter/pagination.d.ts +1 -1
  48. package/dist/interpreter/pagination.js +7 -1
  49. package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
  50. package/dist/interpreter/step-handlers/for-handler.js +18 -3
  51. package/dist/interpreter/step-handlers/match-handler.js +5 -2
  52. package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
  53. package/dist/interpreter/step-handlers/store-handler.js +25 -16
  54. package/dist/interpreter/step-handlers/validate-handler.js +4 -1
  55. package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
  56. package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
  57. package/dist/interpreter/store-manager.d.ts +1 -1
  58. package/dist/interpreter/store-manager.js +5 -1
  59. package/dist/loader/index.js +5 -8
  60. package/dist/mcp/sandbox.d.ts +41 -0
  61. package/dist/mcp/sandbox.js +76 -0
  62. package/dist/mcp/server.js +62 -9
  63. package/dist/oas/loader.d.ts +13 -1
  64. package/dist/oas/loader.js +25 -3
  65. package/dist/oas/mock-generator.js +13 -4
  66. package/dist/oas/validator.js +45 -5
  67. package/dist/observability/events.d.ts +6 -2
  68. package/dist/observability/events.js +0 -5
  69. package/dist/observability/logger.js +17 -10
  70. package/dist/observability/otel.d.ts +8 -0
  71. package/dist/observability/otel.js +45 -10
  72. package/dist/parser/action-parser.js +2 -2
  73. package/dist/parser/base.d.ts +7 -0
  74. package/dist/parser/base.js +11 -0
  75. package/dist/parser/expressions.d.ts +1 -0
  76. package/dist/parser/expressions.js +17 -4
  77. package/dist/parser/fetch-parser.js +13 -2
  78. package/dist/pause/index.d.ts +1 -0
  79. package/dist/pause/index.js +1 -0
  80. package/dist/pause/log-store.d.ts +33 -0
  81. package/dist/pause/log-store.js +98 -0
  82. package/dist/pause/manager.d.ts +12 -0
  83. package/dist/pause/manager.js +77 -28
  84. package/dist/pause/store.js +5 -3
  85. package/dist/scheduler/cron-parser.d.ts +10 -3
  86. package/dist/scheduler/cron-parser.js +227 -48
  87. package/dist/scheduler/scheduler.js +56 -22
  88. package/dist/stores/factory.d.ts +6 -0
  89. package/dist/stores/factory.js +11 -1
  90. package/dist/stores/file.js +9 -17
  91. package/dist/stores/memory.js +3 -12
  92. package/dist/stores/postgrest.d.ts +28 -0
  93. package/dist/stores/postgrest.js +84 -37
  94. package/dist/sync/index.d.ts +3 -2
  95. package/dist/sync/index.js +2 -1
  96. package/dist/sync/log-store.d.ts +30 -0
  97. package/dist/sync/log-store.js +45 -0
  98. package/dist/sync/store.js +1 -1
  99. package/dist/trace/index.d.ts +2 -0
  100. package/dist/trace/index.js +1 -0
  101. package/dist/trace/log-view.d.ts +57 -0
  102. package/dist/trace/log-view.js +76 -0
  103. package/dist/trace/recorder.d.ts +5 -1
  104. package/dist/trace/recorder.js +19 -6
  105. package/dist/trace/store.d.ts +6 -0
  106. package/dist/trace/store.js +47 -22
  107. package/dist/utils/deep-merge.d.ts +10 -0
  108. package/dist/utils/deep-merge.js +23 -0
  109. package/dist/utils/file.d.ts +13 -4
  110. package/dist/utils/file.js +70 -12
  111. package/dist/utils/index.d.ts +1 -1
  112. package/dist/utils/index.js +1 -1
  113. package/dist/utils/long-timeout.d.ts +19 -0
  114. package/dist/utils/long-timeout.js +33 -0
  115. package/dist/utils/path.d.ts +22 -1
  116. package/dist/utils/path.js +46 -1
  117. package/dist/utils/redact.d.ts +22 -0
  118. package/dist/utils/redact.js +42 -0
  119. package/dist/webhook/server.d.ts +9 -0
  120. package/dist/webhook/server.js +115 -30
  121. package/dist/webhook/types.d.ts +9 -1
  122. package/package.json +22 -4
@@ -5,6 +5,7 @@
5
5
  * enabling replay and step-by-step inspection of past runs.
6
6
  */
7
7
  import { join } from 'node:path';
8
+ import { safeJoin } from '../utils/path.js';
8
9
  import { ensureDirectory, writeJsonFile, readJsonFile, listFiles, deleteFile, restoreDates, restoreDatesInArray, } from '../utils/file.js';
9
10
  /**
10
11
  * File-based trace store
@@ -13,12 +14,15 @@ import { ensureDirectory, writeJsonFile, readJsonFile, listFiles, deleteFile, re
13
14
  export class FileTraceStore {
14
15
  baseDir;
15
16
  initialized;
17
+ /** Per-trace append serialization to prevent concurrent index collisions. */
18
+ appendChain = new Map();
16
19
  constructor(baseDir = '.reqon-data/traces') {
17
20
  this.baseDir = baseDir;
18
21
  this.initialized = ensureDirectory(this.baseDir);
19
22
  }
20
23
  getTraceDir(id) {
21
- return join(this.baseDir, id);
24
+ // Confine the (DSL-influenced) trace id to the traces base directory.
25
+ return safeJoin(this.baseDir, id);
22
26
  }
23
27
  getMetadataPath(id) {
24
28
  return join(this.getTraceDir(id), 'metadata.json');
@@ -53,13 +57,23 @@ export class FileTraceStore {
53
57
  metadata: trace.metadata,
54
58
  snapshotCount: trace.snapshots.length,
55
59
  };
56
- await writeJsonFile(this.getMetadataPath(trace.id), metadata);
57
- // Save each snapshot separately for efficient random access
60
+ // Write all snapshots first, then metadata last. Metadata's snapshotCount
61
+ // acts as a commit pointer: a crash mid-save leaves either no metadata or
62
+ // the previous one, never a metadata that claims more snapshots than exist.
58
63
  for (let i = 0; i < trace.snapshots.length; i++) {
59
- await writeJsonFile(this.getSnapshotPath(trace.id, i), trace.snapshots[i]);
64
+ await writeJsonFile(this.getSnapshotPath(trace.id, i), trace.snapshots[i], true, 0o600);
60
65
  }
66
+ await writeJsonFile(this.getMetadataPath(trace.id), metadata, true, 0o600);
61
67
  }
62
68
  async appendSnapshot(traceId, snapshot) {
69
+ // Serialize appends per trace so two concurrent calls can't compute the
70
+ // same next index and clobber each other's snapshot file.
71
+ const run = (this.appendChain.get(traceId) ?? Promise.resolve()).then(() => this.doAppendSnapshot(traceId, snapshot));
72
+ // Keep the chain alive even if this append rejects.
73
+ this.appendChain.set(traceId, run.catch(() => { }));
74
+ return run;
75
+ }
76
+ async doAppendSnapshot(traceId, snapshot) {
63
77
  await this.initialized;
64
78
  const traceDir = this.getTraceDir(traceId);
65
79
  await ensureDirectory(traceDir);
@@ -70,32 +84,34 @@ export class FileTraceStore {
70
84
  if (metadata && typeof metadata.snapshotCount === 'number') {
71
85
  snapshotCount = metadata.snapshotCount;
72
86
  }
73
- // Save the new snapshot
74
- await writeJsonFile(this.getSnapshotPath(traceId, snapshotCount), snapshot);
75
- // Update metadata with new count
87
+ // Write the snapshot first, then commit the new count to metadata so a
88
+ // crash between the two leaves an uncommitted (ignored) snapshot, never a
89
+ // count that points past what's on disk.
90
+ await writeJsonFile(this.getSnapshotPath(traceId, snapshotCount), snapshot, true, 0o600);
76
91
  if (metadata) {
77
92
  metadata.snapshotCount = snapshotCount + 1;
78
- await writeJsonFile(metadataPath, metadata);
93
+ await writeJsonFile(metadataPath, metadata, true, 0o600);
79
94
  }
80
95
  }
81
96
  async load(id) {
82
97
  await this.initialized;
83
- const metadata = await this.getMetadata(id);
84
- if (!metadata)
98
+ const raw = await this.readMetadataRaw(id);
99
+ if (!raw)
85
100
  return null;
86
- // Load all snapshots
101
+ const { snapshotCount, metadata } = raw;
102
+ // Load exactly the committed number of snapshots, by index. Globbing
103
+ // snapshot_*.json would silently include a half-written extra file or
104
+ // miss nothing about a gap; loading by the committed count guarantees the
105
+ // replayed trace matches what save/append actually committed.
87
106
  const snapshots = [];
88
- const snapshotFiles = await listFiles(this.getTraceDir(id), '.json');
89
- for (const file of snapshotFiles) {
90
- if (file.includes('snapshot_')) {
91
- const parsed = await readJsonFile(file);
92
- if (parsed) {
93
- snapshots.push(this.deserializeSnapshot(parsed));
94
- }
107
+ for (let i = 0; i < snapshotCount; i++) {
108
+ const parsed = await readJsonFile(this.getSnapshotPath(id, i));
109
+ if (!parsed) {
110
+ throw new Error(`Trace ${id} is inconsistent: metadata claims ${snapshotCount} snapshots ` +
111
+ `but snapshot ${i} is missing.`);
95
112
  }
113
+ snapshots.push(this.deserializeSnapshot(parsed));
96
114
  }
97
- // Sort by index
98
- snapshots.sort((a, b) => a.index - b.index);
99
115
  return {
100
116
  ...metadata,
101
117
  snapshots,
@@ -144,13 +160,22 @@ export class FileTraceStore {
144
160
  return traces[0] ?? null;
145
161
  }
146
162
  async getMetadata(id) {
163
+ const raw = await this.readMetadataRaw(id);
164
+ return raw ? raw.metadata : null;
165
+ }
166
+ /** Read the metadata file, returning the committed snapshot count and the
167
+ * trace metadata (snapshotCount stripped). */
168
+ async readMetadataRaw(id) {
147
169
  await this.initialized;
148
170
  const parsed = await readJsonFile(this.getMetadataPath(id));
149
171
  if (!parsed)
150
172
  return null;
151
173
  restoreDates(parsed, ['startedAt', 'completedAt']);
152
- const { snapshotCount: _snapshotCount, ...rest } = parsed;
153
- return rest;
174
+ const { snapshotCount, ...rest } = parsed;
175
+ return {
176
+ snapshotCount: typeof snapshotCount === 'number' ? snapshotCount : 0,
177
+ metadata: rest,
178
+ };
154
179
  }
155
180
  }
156
181
  /**
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Deep-merge for store upserts.
3
+ *
4
+ * A shallow `{ ...existing, ...incoming }` clobbers nested objects: merging
5
+ * `{ profile: { city: 'LA' } }` over `{ profile: { name: 'Alice' } }` drops
6
+ * `name`. deepMerge recurses into plain objects so sibling nested fields
7
+ * survive. Arrays and primitives are replaced wholesale (the incoming value
8
+ * wins), and neither input is mutated.
9
+ */
10
+ export declare function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Deep-merge for store upserts.
3
+ *
4
+ * A shallow `{ ...existing, ...incoming }` clobbers nested objects: merging
5
+ * `{ profile: { city: 'LA' } }` over `{ profile: { name: 'Alice' } }` drops
6
+ * `name`. deepMerge recurses into plain objects so sibling nested fields
7
+ * survive. Arrays and primitives are replaced wholesale (the incoming value
8
+ * wins), and neither input is mutated.
9
+ */
10
+ function isPlainObject(value) {
11
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
12
+ return false;
13
+ const proto = Object.getPrototypeOf(value);
14
+ return proto === Object.prototype || proto === null;
15
+ }
16
+ export function deepMerge(target, source) {
17
+ const out = { ...target };
18
+ for (const [key, value] of Object.entries(source)) {
19
+ const existing = out[key];
20
+ out[key] = isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value;
21
+ }
22
+ return out;
23
+ }
@@ -11,12 +11,21 @@ export declare function ensureParentDirectory(filePath: string): Promise<void>;
11
11
  */
12
12
  export declare function serialize(data: unknown, pretty?: boolean): string;
13
13
  /**
14
- * Write JSON data to a file
14
+ * Atomically write a file: write to a temp file, fsync it, then rename over
15
+ * the target. A crash or full disk mid-write leaves the original file intact
16
+ * rather than truncating it (the rename is atomic on POSIX/NTFS).
15
17
  */
16
- export declare function writeJsonFile(filePath: string, data: unknown, pretty?: boolean): Promise<void>;
18
+ export declare function writeFileAtomic(filePath: string, content: string, mode?: number): Promise<void>;
19
+ /** Synchronous counterpart to {@link writeFileAtomic} for flush/exit paths. */
20
+ export declare function writeFileAtomicSync(filePath: string, content: string): void;
17
21
  /**
18
- * Read JSON data from a file
19
- * Returns null if file doesn't exist or is corrupted
22
+ * Write JSON data to a file atomically (durable against mid-write crashes).
23
+ */
24
+ export declare function writeJsonFile(filePath: string, data: unknown, pretty?: boolean, mode?: number): Promise<void>;
25
+ /**
26
+ * Read JSON data from a file.
27
+ * Returns null only if the file is absent; throws if it exists but is corrupt
28
+ * so a truncated/partial file is never silently treated as "empty".
20
29
  */
21
30
  export declare function readJsonFile<T>(filePath: string): Promise<T | null>;
22
31
  /**
@@ -1,5 +1,8 @@
1
- import { mkdir, readFile, writeFile, access, readdir, unlink } from 'node:fs/promises';
1
+ import { mkdir, readFile, access, readdir, unlink, rename, open } from 'node:fs/promises';
2
+ import { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
4
+ // Monotonic counter to keep concurrent temp-file names unique within a process.
5
+ let tmpCounter = 0;
3
6
  /**
4
7
  * Ensure a directory exists, creating it if necessary
5
8
  */
@@ -24,22 +27,79 @@ export function serialize(data, pretty = true) {
24
27
  return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
25
28
  }
26
29
  /**
27
- * Write JSON data to a file
30
+ * Atomically write a file: write to a temp file, fsync it, then rename over
31
+ * the target. A crash or full disk mid-write leaves the original file intact
32
+ * rather than truncating it (the rename is atomic on POSIX/NTFS).
28
33
  */
29
- export async function writeJsonFile(filePath, data, pretty = true) {
30
- await writeFile(filePath, serialize(data, pretty), 'utf-8');
34
+ export async function writeFileAtomic(filePath, content, mode) {
35
+ const tmpPath = `${filePath}.${process.pid}.${tmpCounter++}.tmp`;
36
+ const handle = await open(tmpPath, 'w', mode);
37
+ try {
38
+ await handle.writeFile(content, 'utf-8');
39
+ await handle.sync();
40
+ }
41
+ finally {
42
+ await handle.close();
43
+ }
44
+ try {
45
+ await rename(tmpPath, filePath);
46
+ }
47
+ catch (err) {
48
+ await unlink(tmpPath).catch(() => { });
49
+ throw err;
50
+ }
51
+ }
52
+ /** Synchronous counterpart to {@link writeFileAtomic} for flush/exit paths. */
53
+ export function writeFileAtomicSync(filePath, content) {
54
+ const tmpPath = `${filePath}.${process.pid}.${tmpCounter++}.tmp`;
55
+ const fd = openSync(tmpPath, 'w');
56
+ try {
57
+ writeSync(fd, content);
58
+ fsyncSync(fd);
59
+ }
60
+ finally {
61
+ closeSync(fd);
62
+ }
63
+ try {
64
+ renameSync(tmpPath, filePath);
65
+ }
66
+ catch (err) {
67
+ try {
68
+ unlinkSync(tmpPath);
69
+ }
70
+ catch {
71
+ // best-effort cleanup
72
+ }
73
+ throw err;
74
+ }
75
+ }
76
+ /**
77
+ * Write JSON data to a file atomically (durable against mid-write crashes).
78
+ */
79
+ export async function writeJsonFile(filePath, data, pretty = true, mode) {
80
+ await writeFileAtomic(filePath, serialize(data, pretty), mode);
31
81
  }
32
82
  /**
33
- * Read JSON data from a file
34
- * Returns null if file doesn't exist or is corrupted
83
+ * Read JSON data from a file.
84
+ * Returns null only if the file is absent; throws if it exists but is corrupt
85
+ * so a truncated/partial file is never silently treated as "empty".
35
86
  */
36
87
  export async function readJsonFile(filePath) {
88
+ let content;
89
+ try {
90
+ content = await readFile(filePath, 'utf-8');
91
+ }
92
+ catch (err) {
93
+ if (err.code === 'ENOENT') {
94
+ return null;
95
+ }
96
+ throw err;
97
+ }
37
98
  try {
38
- const content = await readFile(filePath, 'utf-8');
39
99
  return JSON.parse(content);
40
100
  }
41
- catch {
42
- return null;
101
+ catch (err) {
102
+ throw new Error(`Corrupt JSON in ${filePath}: ${err.message}`, { cause: err });
43
103
  }
44
104
  }
45
105
  /**
@@ -48,9 +108,7 @@ export async function readJsonFile(filePath) {
48
108
  export async function listFiles(dir, extension) {
49
109
  try {
50
110
  const entries = await readdir(dir);
51
- const filtered = extension
52
- ? entries.filter((f) => f.endsWith(extension))
53
- : entries;
111
+ const filtered = extension ? entries.filter((f) => f.endsWith(extension)) : entries;
54
112
  return filtered.map((f) => join(dir, f));
55
113
  }
56
114
  catch {
@@ -1,5 +1,5 @@
1
1
  export { sleep } from './async.js';
2
- export { extractNestedValue, traversePath } from './path.js';
2
+ export { extractNestedValue, traversePath, safeJoin, sanitizeSegment, PathTraversalError, } from './path.js';
3
3
  export { type Logger, ConsoleLogger, SilentLogger, createLogger } from './logger.js';
4
4
  export { ensureDirectory, ensureParentDirectory, serialize, writeJsonFile, readJsonFile, listFiles, deleteFile, restoreDates, restoreDatesInArray, } from './file.js';
5
5
  export { isRecord, isObject, isArrayOf, isString, isNumber, isBoolean, isDefined, isPresent, getProperty, getNestedProperty, hasProperty, hasTypedProperty, } from './type-guards.js';
@@ -1,5 +1,5 @@
1
1
  export { sleep } from './async.js';
2
- export { extractNestedValue, traversePath } from './path.js';
2
+ export { extractNestedValue, traversePath, safeJoin, sanitizeSegment, PathTraversalError, } from './path.js';
3
3
  export { ConsoleLogger, SilentLogger, createLogger } from './logger.js';
4
4
  export { ensureDirectory, ensureParentDirectory, serialize, writeJsonFile, readJsonFile, listFiles, deleteFile, restoreDates, restoreDatesInArray, } from './file.js';
5
5
  export { isRecord, isObject, isArrayOf, isString, isNumber, isBoolean, isDefined, isPresent, getProperty, getNestedProperty, hasProperty, hasTypedProperty, } from './type-guards.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Timers for delays beyond Node's 32-bit setTimeout limit.
3
+ *
4
+ * Node stores the delay in a 32-bit signed int: anything over 2_147_483_647 ms
5
+ * (~24.8 days) overflows and the callback fires almost immediately. A multi-week
6
+ * webhook wait or pause would therefore time out at once. `setLongTimeout`
7
+ * chains native timeouts so arbitrarily long delays fire at the right time.
8
+ */
9
+ /** Largest delay (ms) Node's setTimeout handles without overflowing. */
10
+ export declare const MAX_TIMEOUT_MS = 2147483647;
11
+ export interface LongTimeout {
12
+ /** Cancel the pending timeout. */
13
+ clear(): void;
14
+ }
15
+ /**
16
+ * Schedule `callback` to run after `delayMs`, correctly handling delays larger
17
+ * than {@link MAX_TIMEOUT_MS} by chaining native timeouts.
18
+ */
19
+ export declare function setLongTimeout(callback: () => void, delayMs: number): LongTimeout;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Timers for delays beyond Node's 32-bit setTimeout limit.
3
+ *
4
+ * Node stores the delay in a 32-bit signed int: anything over 2_147_483_647 ms
5
+ * (~24.8 days) overflows and the callback fires almost immediately. A multi-week
6
+ * webhook wait or pause would therefore time out at once. `setLongTimeout`
7
+ * chains native timeouts so arbitrarily long delays fire at the right time.
8
+ */
9
+ /** Largest delay (ms) Node's setTimeout handles without overflowing. */
10
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
11
+ /**
12
+ * Schedule `callback` to run after `delayMs`, correctly handling delays larger
13
+ * than {@link MAX_TIMEOUT_MS} by chaining native timeouts.
14
+ */
15
+ export function setLongTimeout(callback, delayMs) {
16
+ let remaining = Math.max(0, delayMs);
17
+ let timer;
18
+ const schedule = () => {
19
+ if (remaining <= MAX_TIMEOUT_MS) {
20
+ timer = setTimeout(callback, remaining);
21
+ }
22
+ else {
23
+ remaining -= MAX_TIMEOUT_MS;
24
+ timer = setTimeout(schedule, MAX_TIMEOUT_MS);
25
+ }
26
+ };
27
+ schedule();
28
+ return {
29
+ clear() {
30
+ clearTimeout(timer);
31
+ },
32
+ };
33
+ }
@@ -7,6 +7,27 @@
7
7
  */
8
8
  export declare function extractNestedValue(data: Record<string, unknown>, path: string): unknown;
9
9
  /**
10
- * Traverse an object path and return the value, with fallback lookup
10
+ * Error thrown when a path segment would escape its base directory.
11
11
  */
12
+ export declare class PathTraversalError extends Error {
13
+ readonly segment: string;
14
+ readonly baseDir: string;
15
+ constructor(segment: string, baseDir: string);
16
+ }
17
+ /**
18
+ * Safely join an untrusted path segment under a base directory, asserting the
19
+ * result stays confined inside that base. Guards against `../`, absolute paths,
20
+ * and embedded separators in store names / IDs.
21
+ *
22
+ * @throws {PathTraversalError} if the resolved path escapes baseDir
23
+ */
24
+ export declare function safeJoin(baseDir: string, ...segments: string[]): string;
25
+ /**
26
+ * Sanitize a single untrusted path segment (e.g. a store name or record ID) so
27
+ * it cannot traverse directories. Rejects empty, separator-bearing, or
28
+ * traversal segments rather than silently mangling them.
29
+ *
30
+ * @throws {PathTraversalError} if the segment is unsafe
31
+ */
32
+ export declare function sanitizeSegment(segment: string, baseDir?: string): string;
12
33
  export declare function traversePath(parts: string[], current: unknown, fallbackLookup?: (key: string) => unknown): unknown;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Utility functions for path traversal and value extraction
3
3
  */
4
+ import { join, resolve, sep } from 'node:path';
4
5
  /**
5
6
  * Extract a nested value from an object using dot notation path
6
7
  * @example extractNestedValue({ a: { b: 1 } }, 'a.b') // => 1
@@ -19,8 +20,52 @@ export function extractNestedValue(data, path) {
19
20
  return value;
20
21
  }
21
22
  /**
22
- * Traverse an object path and return the value, with fallback lookup
23
+ * Error thrown when a path segment would escape its base directory.
23
24
  */
25
+ export class PathTraversalError extends Error {
26
+ segment;
27
+ baseDir;
28
+ constructor(segment, baseDir) {
29
+ super(`Path segment "${segment}" escapes base directory "${baseDir}"`);
30
+ this.segment = segment;
31
+ this.baseDir = baseDir;
32
+ this.name = 'PathTraversalError';
33
+ }
34
+ }
35
+ /**
36
+ * Safely join an untrusted path segment under a base directory, asserting the
37
+ * result stays confined inside that base. Guards against `../`, absolute paths,
38
+ * and embedded separators in store names / IDs.
39
+ *
40
+ * @throws {PathTraversalError} if the resolved path escapes baseDir
41
+ */
42
+ export function safeJoin(baseDir, ...segments) {
43
+ const base = resolve(baseDir);
44
+ const joined = resolve(base, ...segments);
45
+ // Confined if it equals the base or sits inside it (base + separator prefix).
46
+ if (joined !== base && !joined.startsWith(base + sep)) {
47
+ throw new PathTraversalError(join(...segments), baseDir);
48
+ }
49
+ return joined;
50
+ }
51
+ /**
52
+ * Sanitize a single untrusted path segment (e.g. a store name or record ID) so
53
+ * it cannot traverse directories. Rejects empty, separator-bearing, or
54
+ * traversal segments rather than silently mangling them.
55
+ *
56
+ * @throws {PathTraversalError} if the segment is unsafe
57
+ */
58
+ export function sanitizeSegment(segment, baseDir = '.') {
59
+ if (segment.length === 0 ||
60
+ segment === '.' ||
61
+ segment === '..' ||
62
+ segment.includes('/') ||
63
+ segment.includes('\\') ||
64
+ segment.includes('\0')) {
65
+ throw new PathTraversalError(segment, baseDir);
66
+ }
67
+ return segment;
68
+ }
24
69
  export function traversePath(parts, current, fallbackLookup) {
25
70
  let value = current;
26
71
  for (let i = 0; i < parts.length; i++) {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Secret redaction for logs, error messages, and durable trace files.
3
+ *
4
+ * Uses a denylist of sensitive key-name patterns: any object property whose
5
+ * key looks like a credential is replaced with {@link REDACTED}. This is a
6
+ * best-effort guard — it cannot catch a raw secret stored under an innocuous
7
+ * key — so callers should also avoid persisting cleartext credentials.
8
+ */
9
+ export declare const REDACTED = "[REDACTED]";
10
+ /** True when a key name looks like it holds a credential. */
11
+ export declare function isSensitiveKey(key: string): boolean;
12
+ /**
13
+ * Return a deep copy of `value` with any credential-looking properties
14
+ * replaced by {@link REDACTED}. Non-objects are returned unchanged. Cycles are
15
+ * handled, and the input is never mutated.
16
+ */
17
+ export declare function redactSecrets<T>(value: T, seen?: WeakSet<object>): T;
18
+ /**
19
+ * Redact a named value for logging: if the name itself looks sensitive the
20
+ * whole value is hidden; otherwise nested credential properties are redacted.
21
+ */
22
+ export declare function redactNamedValue(name: string, value: unknown): unknown;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Secret redaction for logs, error messages, and durable trace files.
3
+ *
4
+ * Uses a denylist of sensitive key-name patterns: any object property whose
5
+ * key looks like a credential is replaced with {@link REDACTED}. This is a
6
+ * best-effort guard — it cannot catch a raw secret stored under an innocuous
7
+ * key — so callers should also avoid persisting cleartext credentials.
8
+ */
9
+ export const REDACTED = '[REDACTED]';
10
+ /** Key names that should never have their value logged or persisted in clear. */
11
+ const SENSITIVE_KEY = /(pass(word|wd)?|secret|token|auth(orization)?|api[-_]?key|access[-_]?key|client[-_]?secret|credential|cookie|session|bearer|private[-_]?key)/i;
12
+ /** True when a key name looks like it holds a credential. */
13
+ export function isSensitiveKey(key) {
14
+ return SENSITIVE_KEY.test(key);
15
+ }
16
+ /**
17
+ * Return a deep copy of `value` with any credential-looking properties
18
+ * replaced by {@link REDACTED}. Non-objects are returned unchanged. Cycles are
19
+ * handled, and the input is never mutated.
20
+ */
21
+ export function redactSecrets(value, seen = new WeakSet()) {
22
+ if (value === null || typeof value !== 'object')
23
+ return value;
24
+ if (seen.has(value))
25
+ return value;
26
+ seen.add(value);
27
+ if (Array.isArray(value)) {
28
+ return value.map((item) => redactSecrets(item, seen));
29
+ }
30
+ const out = {};
31
+ for (const [key, val] of Object.entries(value)) {
32
+ out[key] = isSensitiveKey(key) ? REDACTED : redactSecrets(val, seen);
33
+ }
34
+ return out;
35
+ }
36
+ /**
37
+ * Redact a named value for logging: if the name itself looks sensitive the
38
+ * whole value is hidden; otherwise nested credential properties are redacted.
39
+ */
40
+ export function redactNamedValue(name, value) {
41
+ return isSensitiveKey(name) ? REDACTED : redactSecrets(value);
42
+ }
@@ -45,6 +45,8 @@ export declare class WebhookServer {
45
45
  * Wait for webhook events
46
46
  */
47
47
  waitForEvents(registrationId: string, timeout?: number): Promise<WaitResult>;
48
+ /** Remove a single waiter, dropping the registration's set when empty. */
49
+ private removePendingWait;
48
50
  /**
49
51
  * Unregister a webhook endpoint
50
52
  */
@@ -69,6 +71,13 @@ export declare class WebhookServer {
69
71
  * Read request body
70
72
  */
71
73
  private readBody;
74
+ /** True if a host string is a loopback address. */
75
+ private isLoopback;
76
+ /**
77
+ * Validate the shared secret from Authorization bearer, X-Webhook-Token
78
+ * header, or a `token` query param.
79
+ */
80
+ private authorized;
72
81
  /**
73
82
  * Extract headers from request
74
83
  */