@blokjs/runner 0.2.2 → 0.6.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 (213) hide show
  1. package/dist/Blok.js +32 -3
  2. package/dist/Blok.js.map +1 -1
  3. package/dist/Configuration.d.ts +59 -5
  4. package/dist/Configuration.js +366 -96
  5. package/dist/Configuration.js.map +1 -1
  6. package/dist/ForEachNode.d.ts +59 -0
  7. package/dist/ForEachNode.js +522 -0
  8. package/dist/ForEachNode.js.map +1 -0
  9. package/dist/LoopMaxIterationsError.d.ts +11 -0
  10. package/dist/LoopMaxIterationsError.js +18 -0
  11. package/dist/LoopMaxIterationsError.js.map +1 -0
  12. package/dist/LoopNode.d.ts +36 -0
  13. package/dist/LoopNode.js +182 -0
  14. package/dist/LoopNode.js.map +1 -0
  15. package/dist/PayloadTooLargeError.d.ts +19 -0
  16. package/dist/PayloadTooLargeError.js +29 -0
  17. package/dist/PayloadTooLargeError.js.map +1 -0
  18. package/dist/RunCancelledError.d.ts +17 -0
  19. package/dist/RunCancelledError.js +25 -0
  20. package/dist/RunCancelledError.js.map +1 -0
  21. package/dist/Runner.d.ts +11 -1
  22. package/dist/Runner.js +9 -2
  23. package/dist/Runner.js.map +1 -1
  24. package/dist/RunnerSteps.js +648 -44
  25. package/dist/RunnerSteps.js.map +1 -1
  26. package/dist/RuntimeAdapterNode.d.ts +2 -1
  27. package/dist/RuntimeAdapterNode.js +2 -2
  28. package/dist/RuntimeAdapterNode.js.map +1 -1
  29. package/dist/RuntimeRegistry.d.ts +23 -2
  30. package/dist/RuntimeRegistry.js +31 -2
  31. package/dist/RuntimeRegistry.js.map +1 -1
  32. package/dist/SubworkflowNode.d.ts +181 -0
  33. package/dist/SubworkflowNode.js +479 -0
  34. package/dist/SubworkflowNode.js.map +1 -0
  35. package/dist/SwitchNode.d.ts +37 -0
  36. package/dist/SwitchNode.js +153 -0
  37. package/dist/SwitchNode.js.map +1 -0
  38. package/dist/TriggerBase.d.ts +178 -0
  39. package/dist/TriggerBase.js +1032 -5
  40. package/dist/TriggerBase.js.map +1 -1
  41. package/dist/TryCatchNode.d.ts +32 -0
  42. package/dist/TryCatchNode.js +207 -0
  43. package/dist/TryCatchNode.js.map +1 -0
  44. package/dist/WaitDispatchRequest.d.ts +38 -0
  45. package/dist/WaitDispatchRequest.js +13 -0
  46. package/dist/WaitDispatchRequest.js.map +1 -0
  47. package/dist/WaitNode.d.ts +23 -0
  48. package/dist/WaitNode.js +26 -0
  49. package/dist/WaitNode.js.map +1 -0
  50. package/dist/adapters/grpc/GrpcCodec.js +2 -2
  51. package/dist/adapters/grpc/GrpcRuntimeAdapter.d.ts +6 -4
  52. package/dist/adapters/grpc/GrpcRuntimeAdapter.js +6 -4
  53. package/dist/adapters/grpc/GrpcRuntimeAdapter.js.map +1 -1
  54. package/dist/adapters/grpc/types.d.ts +7 -5
  55. package/dist/adapters/grpc/types.js.map +1 -1
  56. package/dist/adapters/transport.d.ts +12 -41
  57. package/dist/adapters/transport.js +21 -70
  58. package/dist/adapters/transport.js.map +1 -1
  59. package/dist/cache/NodeResultCache.js +7 -0
  60. package/dist/cache/NodeResultCache.js.map +1 -1
  61. package/dist/concurrency/ConcurrencyBackend.d.ts +61 -0
  62. package/dist/concurrency/ConcurrencyBackend.js +20 -0
  63. package/dist/concurrency/ConcurrencyBackend.js.map +1 -0
  64. package/dist/concurrency/ConcurrencyLimitError.d.ts +37 -0
  65. package/dist/concurrency/ConcurrencyLimitError.js +16 -0
  66. package/dist/concurrency/ConcurrencyLimitError.js.map +1 -0
  67. package/dist/concurrency/NatsKvConcurrencyBackend.d.ts +64 -0
  68. package/dist/concurrency/NatsKvConcurrencyBackend.js +310 -0
  69. package/dist/concurrency/NatsKvConcurrencyBackend.js.map +1 -0
  70. package/dist/concurrency/QueueExpiredError.d.ts +40 -0
  71. package/dist/concurrency/QueueExpiredError.js +15 -0
  72. package/dist/concurrency/QueueExpiredError.js.map +1 -0
  73. package/dist/concurrency/RedisConcurrencyBackend.d.ts +64 -0
  74. package/dist/concurrency/RedisConcurrencyBackend.js +374 -0
  75. package/dist/concurrency/RedisConcurrencyBackend.js.map +1 -0
  76. package/dist/concurrency/createConcurrencyBackend.d.ts +24 -0
  77. package/dist/concurrency/createConcurrencyBackend.js +38 -0
  78. package/dist/concurrency/createConcurrencyBackend.js.map +1 -0
  79. package/dist/concurrency/readConcurrencyConfig.d.ts +60 -0
  80. package/dist/concurrency/readConcurrencyConfig.js +60 -0
  81. package/dist/concurrency/readConcurrencyConfig.js.map +1 -0
  82. package/dist/defineNode.d.ts +8 -0
  83. package/dist/defineNode.js +25 -5
  84. package/dist/defineNode.js.map +1 -1
  85. package/dist/graphql/GraphQLSchemaGenerator.js +1 -1
  86. package/dist/graphql/GraphQLSchemaGenerator.js.map +1 -1
  87. package/dist/idempotency/resolveIdempotencyKey.d.ts +20 -0
  88. package/dist/idempotency/resolveIdempotencyKey.js +37 -0
  89. package/dist/idempotency/resolveIdempotencyKey.js.map +1 -0
  90. package/dist/index.d.ts +30 -6
  91. package/dist/index.js +55 -6
  92. package/dist/index.js.map +1 -1
  93. package/dist/marketplace/RuntimeCatalog.d.ts +6 -0
  94. package/dist/marketplace/RuntimeCatalog.js.map +1 -1
  95. package/dist/marketplace/RuntimeDiscovery.d.ts +2 -2
  96. package/dist/marketplace/RuntimeDiscovery.js +18 -6
  97. package/dist/marketplace/RuntimeDiscovery.js.map +1 -1
  98. package/dist/monitoring/ConcurrencyMetrics.d.ts +82 -0
  99. package/dist/monitoring/ConcurrencyMetrics.js +139 -0
  100. package/dist/monitoring/ConcurrencyMetrics.js.map +1 -0
  101. package/dist/monitoring/ForEachWaitMetrics.d.ts +22 -0
  102. package/dist/monitoring/ForEachWaitMetrics.js +36 -0
  103. package/dist/monitoring/ForEachWaitMetrics.js.map +1 -0
  104. package/dist/monitoring/JanitorMetrics.d.ts +27 -0
  105. package/dist/monitoring/JanitorMetrics.js +48 -0
  106. package/dist/monitoring/JanitorMetrics.js.map +1 -0
  107. package/dist/openapi/OpenAPIGenerator.js +7 -2
  108. package/dist/openapi/OpenAPIGenerator.js.map +1 -1
  109. package/dist/runtime/PrimitiveStack.d.ts +64 -0
  110. package/dist/runtime/PrimitiveStack.js +92 -0
  111. package/dist/runtime/PrimitiveStack.js.map +1 -0
  112. package/dist/scheduling/DebounceBackend.d.ts +108 -0
  113. package/dist/scheduling/DebounceBackend.js +23 -0
  114. package/dist/scheduling/DebounceBackend.js.map +1 -0
  115. package/dist/scheduling/DebounceCoordinator.d.ts +141 -0
  116. package/dist/scheduling/DebounceCoordinator.js +362 -0
  117. package/dist/scheduling/DebounceCoordinator.js.map +1 -0
  118. package/dist/scheduling/DeferredDispatchSignal.d.ts +50 -0
  119. package/dist/scheduling/DeferredDispatchSignal.js +14 -0
  120. package/dist/scheduling/DeferredDispatchSignal.js.map +1 -0
  121. package/dist/scheduling/DeferredRunScheduler.d.ts +96 -0
  122. package/dist/scheduling/DeferredRunScheduler.js +256 -0
  123. package/dist/scheduling/DeferredRunScheduler.js.map +1 -0
  124. package/dist/scheduling/NatsKvDebounceBackend.d.ts +53 -0
  125. package/dist/scheduling/NatsKvDebounceBackend.js +334 -0
  126. package/dist/scheduling/NatsKvDebounceBackend.js.map +1 -0
  127. package/dist/scheduling/RedisDebounceBackend.d.ts +49 -0
  128. package/dist/scheduling/RedisDebounceBackend.js +356 -0
  129. package/dist/scheduling/RedisDebounceBackend.js.map +1 -0
  130. package/dist/scheduling/createDebounceBackend.d.ts +25 -0
  131. package/dist/scheduling/createDebounceBackend.js +39 -0
  132. package/dist/scheduling/createDebounceBackend.js.map +1 -0
  133. package/dist/scheduling/readSchedulingConfig.d.ts +24 -0
  134. package/dist/scheduling/readSchedulingConfig.js +52 -0
  135. package/dist/scheduling/readSchedulingConfig.js.map +1 -0
  136. package/dist/security/AuditLogger.js +1 -1
  137. package/dist/security/AuditLogger.js.map +1 -1
  138. package/dist/security/AuthMiddleware.d.ts +19 -20
  139. package/dist/security/AuthMiddleware.js +35 -20
  140. package/dist/security/AuthMiddleware.js.map +1 -1
  141. package/dist/security/OAuthProvider.js +2 -2
  142. package/dist/security/OAuthProvider.js.map +1 -1
  143. package/dist/security/SecretManager.js +14 -13
  144. package/dist/security/SecretManager.js.map +1 -1
  145. package/dist/security/index.d.ts +3 -1
  146. package/dist/security/index.js +3 -1
  147. package/dist/security/index.js.map +1 -1
  148. package/dist/testing/TestHarness.d.ts +27 -12
  149. package/dist/testing/TestHarness.js +19 -3
  150. package/dist/testing/TestHarness.js.map +1 -1
  151. package/dist/testing/WorkflowTestRunner.js +0 -7
  152. package/dist/testing/WorkflowTestRunner.js.map +1 -1
  153. package/dist/timeouts/StepTimeoutError.d.ts +22 -0
  154. package/dist/timeouts/StepTimeoutError.js +31 -0
  155. package/dist/timeouts/StepTimeoutError.js.map +1 -0
  156. package/dist/tracing/InMemoryRunStore.d.ts +41 -1
  157. package/dist/tracing/InMemoryRunStore.js +239 -0
  158. package/dist/tracing/InMemoryRunStore.js.map +1 -1
  159. package/dist/tracing/Janitor.d.ts +70 -0
  160. package/dist/tracing/Janitor.js +150 -0
  161. package/dist/tracing/Janitor.js.map +1 -0
  162. package/dist/tracing/PostgresRunStore.d.ts +57 -1
  163. package/dist/tracing/PostgresRunStore.js +711 -6
  164. package/dist/tracing/PostgresRunStore.js.map +1 -1
  165. package/dist/tracing/RoutingDiagnostics.d.ts +55 -0
  166. package/dist/tracing/RoutingDiagnostics.js +50 -0
  167. package/dist/tracing/RoutingDiagnostics.js.map +1 -0
  168. package/dist/tracing/RunStore.d.ts +181 -1
  169. package/dist/tracing/RunTracker.d.ts +244 -9
  170. package/dist/tracing/RunTracker.js +594 -1
  171. package/dist/tracing/RunTracker.js.map +1 -1
  172. package/dist/tracing/SqliteRunStore.d.ts +79 -2
  173. package/dist/tracing/SqliteRunStore.js +775 -16
  174. package/dist/tracing/SqliteRunStore.js.map +1 -1
  175. package/dist/tracing/TraceRouter.d.ts +20 -2
  176. package/dist/tracing/TraceRouter.js +612 -6
  177. package/dist/tracing/TraceRouter.js.map +1 -1
  178. package/dist/tracing/createStore.js +14 -3
  179. package/dist/tracing/createStore.js.map +1 -1
  180. package/dist/tracing/metadataFilter.d.ts +63 -0
  181. package/dist/tracing/metadataFilter.js +224 -0
  182. package/dist/tracing/metadataFilter.js.map +1 -0
  183. package/dist/tracing/sanitize.d.ts +11 -0
  184. package/dist/tracing/sanitize.js +29 -0
  185. package/dist/tracing/sanitize.js.map +1 -1
  186. package/dist/tracing/types.d.ts +672 -2
  187. package/dist/utils/createChildContext.d.ts +32 -0
  188. package/dist/utils/createChildContext.js +113 -0
  189. package/dist/utils/createChildContext.js.map +1 -0
  190. package/dist/utils/envAllowlist.d.ts +35 -0
  191. package/dist/utils/envAllowlist.js +113 -0
  192. package/dist/utils/envAllowlist.js.map +1 -0
  193. package/dist/version/RuntimeVersionValidator.d.ts +38 -0
  194. package/dist/version/RuntimeVersionValidator.js +121 -0
  195. package/dist/version/RuntimeVersionValidator.js.map +1 -0
  196. package/dist/visualization/WorkflowVisualizer.js +4 -4
  197. package/dist/visualization/WorkflowVisualizer.js.map +1 -1
  198. package/dist/workflow/PersistenceHelper.d.ts +18 -10
  199. package/dist/workflow/PersistenceHelper.js +35 -9
  200. package/dist/workflow/PersistenceHelper.js.map +1 -1
  201. package/dist/workflow/WorkflowNormalizer.d.ts +48 -42
  202. package/dist/workflow/WorkflowNormalizer.js +650 -18
  203. package/dist/workflow/WorkflowNormalizer.js.map +1 -1
  204. package/dist/workflow/WorkflowRegistry.d.ts +186 -0
  205. package/dist/workflow/WorkflowRegistry.js +202 -0
  206. package/dist/workflow/WorkflowRegistry.js.map +1 -0
  207. package/dist/workflow/sampleBody.d.ts +54 -0
  208. package/dist/workflow/sampleBody.js +320 -0
  209. package/dist/workflow/sampleBody.js.map +1 -0
  210. package/package.json +3 -8
  211. package/dist/adapters/HttpRuntimeAdapter.d.ts +0 -79
  212. package/dist/adapters/HttpRuntimeAdapter.js +0 -233
  213. package/dist/adapters/HttpRuntimeAdapter.js.map +0 -1
@@ -1,5 +1,191 @@
1
1
  import http from "node:http";
2
+ import { DebounceCoordinator } from "../scheduling/DebounceCoordinator";
3
+ import { DeferredRunScheduler } from "../scheduling/DeferredRunScheduler";
4
+ import { WorkflowRegistry } from "../workflow/WorkflowRegistry";
5
+ import { inferSampleBody } from "../workflow/sampleBody";
6
+ import { RoutingDiagnostics } from "./RoutingDiagnostics";
2
7
  import { RunTracker } from "./RunTracker";
8
+ import { METADATA_OPERATORS, isValidMetadataKey } from "./metadataFilter";
9
+ /**
10
+ * Security review FW-2 — sensitive headers that are NEVER honored when
11
+ * supplied via the replay endpoint's `overrides.headers`. Combined with
12
+ * the FW-1 trace-auth gate, this blocks the replay-as-auth-bypass attack
13
+ * where an unauthenticated client posts to `/__blok/runs/:id/replay`
14
+ * with an attacker-controlled `Authorization` header that the runner
15
+ * would otherwise dispatch verbatim to the user-authored route.
16
+ */
17
+ const REPLAY_HEADER_DENYLIST = new Set([
18
+ "authorization",
19
+ "cookie",
20
+ "set-cookie",
21
+ "x-api-key",
22
+ "x-auth-token",
23
+ "proxy-authorization",
24
+ ]);
25
+ function filterReplayHeaders(headers) {
26
+ if (!headers)
27
+ return {};
28
+ const filtered = {};
29
+ for (const [k, v] of Object.entries(headers)) {
30
+ if (REPLAY_HEADER_DENYLIST.has(k.toLowerCase()))
31
+ continue;
32
+ filtered[k] = v;
33
+ }
34
+ return filtered;
35
+ }
36
+ /**
37
+ * Coerce a query-string `?limit=...` / `?offset=...` value to a clamped
38
+ * integer. Strings parse via `Number.parseInt`; anything non-finite (or
39
+ * outside `[min, max]`) falls back to `fallback`. Used by paginated GET
40
+ * endpoints so Studio queries can't pin the event loop with absurd
41
+ * window sizes.
42
+ */
43
+ function clampInt(raw, min, max, fallback) {
44
+ if (typeof raw !== "string" || raw.length === 0)
45
+ return fallback;
46
+ const n = Number.parseInt(raw, 10);
47
+ if (!Number.isFinite(n) || Number.isNaN(n))
48
+ return fallback;
49
+ if (n < min)
50
+ return min;
51
+ if (n > max)
52
+ return max;
53
+ return n;
54
+ }
55
+ /**
56
+ * F2 (v0.5) — parse `metadata.<key>[__op]=<value>` query params into
57
+ * the operator-aware `MetadataFilter[]` shape.
58
+ *
59
+ * Supported suffixes (`__op`): `eq` (default), `ne`, `gt`, `gte`,
60
+ * `lt`, `lte`, `like`, `in`, `nin`. Unknown suffixes silently drop —
61
+ * preserves the v0.4 contract that unrecognised query keys are ignored
62
+ * rather than rejected (operators got the same treatment as the v0.4
63
+ * key-shape validation).
64
+ *
65
+ * `in` / `nin` values are comma-split:
66
+ * `metadata.region__in=us,eu,ap` → value: ["us", "eu", "ap"]
67
+ *
68
+ * Keys outside `^[a-zA-Z0-9_-]+$` silently drop — same SQL-injection
69
+ * guard the v0.4 parser already applied.
70
+ */
71
+ function parseMetadataFiltersFromQuery(query) {
72
+ let filters;
73
+ const opSet = new Set(METADATA_OPERATORS);
74
+ for (const [rawKey, value] of Object.entries(query)) {
75
+ if (!rawKey.startsWith("metadata."))
76
+ continue;
77
+ if (typeof value !== "string" || value.length === 0)
78
+ continue;
79
+ const remainder = rawKey.slice("metadata.".length);
80
+ if (remainder.length === 0)
81
+ continue;
82
+ // Suffix split — accepts `key`, `key__op`. Multiple `__` in a
83
+ // key name are tolerated; only the FINAL `__op` is interpreted
84
+ // as the operator when it matches the operator set.
85
+ const opIdx = remainder.lastIndexOf("__");
86
+ let metaKey;
87
+ let op;
88
+ if (opIdx > 0 && opSet.has(remainder.slice(opIdx + 2))) {
89
+ metaKey = remainder.slice(0, opIdx);
90
+ op = remainder.slice(opIdx + 2);
91
+ }
92
+ else {
93
+ metaKey = remainder;
94
+ op = "eq";
95
+ }
96
+ if (!isValidMetadataKey(metaKey))
97
+ continue;
98
+ const parsedValue = op === "in" || op === "nin"
99
+ ? value
100
+ .split(",")
101
+ .map((s) => s.trim())
102
+ .filter((s) => s.length > 0)
103
+ : value;
104
+ if (Array.isArray(parsedValue) && parsedValue.length === 0)
105
+ continue;
106
+ if (!filters)
107
+ filters = [];
108
+ filters.push({ key: metaKey, op, value: parsedValue });
109
+ }
110
+ return filters;
111
+ }
112
+ /**
113
+ * Strip sensitive request headers from a scheduled-dispatch payload
114
+ * before serving it to Studio. `extractDispatchPayload` already strips
115
+ * these at PERSIST time (see `HttpTrigger.extractDispatchPayload`); we
116
+ * re-apply the denylist on read as a belt-and-braces guard against
117
+ * older sqlite rows that pre-date that strip path.
118
+ */
119
+ function sanitizeDispatchPayload(payload) {
120
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
121
+ return payload;
122
+ const obj = payload;
123
+ const headers = obj.headers;
124
+ if (!headers || typeof headers !== "object" || Array.isArray(headers))
125
+ return payload;
126
+ const filtered = {};
127
+ for (const [k, v] of Object.entries(headers)) {
128
+ if (REPLAY_HEADER_DENYLIST.has(k.toLowerCase()))
129
+ continue;
130
+ filtered[k] = v;
131
+ }
132
+ return { ...obj, headers: filtered };
133
+ }
134
+ /**
135
+ * Synthesize a zero-stat `WorkflowSummary` from a `WorkflowRegistry`
136
+ * entry that has never run. Studio's sidebar consumes `/__blok/workflows`,
137
+ * so without this merge a workflow that's registered + bound to a URL
138
+ * but hasn't been triggered yet is invisible to the operator — they
139
+ * have no row to click into, can't open the Graph tab, and (the bug
140
+ * that motivated this) can't even tell the workflow exists.
141
+ *
142
+ * Returns `null` for middleware-only workflows (`isMiddleware: true`)
143
+ * and for entries without a recognised trigger kind — both are
144
+ * non-user-facing.
145
+ */
146
+ function synthesizeRegistryOnlySummary(reg) {
147
+ if (reg.isMiddleware)
148
+ return null;
149
+ const wf = reg.workflow;
150
+ if (!wf || typeof wf !== "object")
151
+ return null;
152
+ const trigger = wf.trigger;
153
+ if (!trigger || typeof trigger !== "object")
154
+ return null;
155
+ const triggerTypes = [];
156
+ let primaryPath;
157
+ for (const [kind, raw] of Object.entries(trigger)) {
158
+ if (!raw || typeof raw !== "object")
159
+ continue;
160
+ triggerTypes.push(kind);
161
+ if (primaryPath !== undefined)
162
+ continue;
163
+ // Pick the first identifying field that exists. Mirrors the
164
+ // `workflow_path` column the SQL summaries return — that's the
165
+ // runtime-set URL, here we approximate with the trigger config.
166
+ const r = raw;
167
+ if (typeof r.path === "string")
168
+ primaryPath = r.path;
169
+ else if (typeof r.queue === "string")
170
+ primaryPath = r.queue;
171
+ else if (typeof r.schedule === "string")
172
+ primaryPath = r.schedule;
173
+ else if (typeof r.topic === "string")
174
+ primaryPath = r.topic;
175
+ }
176
+ if (triggerTypes.length === 0)
177
+ return null;
178
+ return {
179
+ name: reg.name,
180
+ path: primaryPath ?? "/",
181
+ triggerTypes,
182
+ totalRuns: 0,
183
+ recentRuns: 0,
184
+ errorRate: 0,
185
+ avgDurationMs: 0,
186
+ p95DurationMs: 0,
187
+ };
188
+ }
3
189
  /**
4
190
  * Register trace API routes on an Express-compatible router.
5
191
  *
@@ -12,22 +198,64 @@ import { RunTracker } from "./RunTracker";
12
198
  * import { Router } from "express";
13
199
  * import { registerTraceRoutes } from "@blokjs/runner";
14
200
  * const traceRouter = Router();
15
- * registerTraceRoutes(traceRouter);
201
+ * registerTraceRoutes(traceRouter, undefined, { authorize: myAuthFn });
16
202
  * app.use("/__blok", traceRouter);
17
203
  * ```
18
204
  */
19
- export function registerTraceRoutes(router, tracker) {
205
+ export function registerTraceRoutes(router, tracker, options) {
20
206
  const t = tracker || RunTracker.getInstance();
21
207
  // --- CORS for cross-origin Studio UI ---
208
+ // Security review FW-4 — `BLOK_TRACE_CORS_ORIGIN` overrides the
209
+ // permissive `*` default. Set to a single allow-listed origin in
210
+ // production to prevent cross-origin reads of trace data.
211
+ const corsOrigin = process.env.BLOK_TRACE_CORS_ORIGIN || "*";
212
+ // Security review FW-1 — production-default-deny on /__blok/* unless
213
+ // the operator either registers an authorize hook (preferred) or
214
+ // explicitly opts out via BLOK_TRACE_AUTH_DISABLED=1.
215
+ const isProd = process.env.BLOK_ENV === "production" || process.env.NODE_ENV === "production";
216
+ const authDisabled = process.env.BLOK_TRACE_AUTH_DISABLED === "1";
217
+ const authorize = options?.authorize;
22
218
  router.use((req, res, next) => {
23
- res.setHeader("Access-Control-Allow-Origin", "*");
219
+ res.setHeader("Access-Control-Allow-Origin", corsOrigin);
24
220
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
25
221
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Last-Event-ID");
26
222
  if (req.method === "OPTIONS") {
27
223
  res.sendStatus(204);
28
224
  return;
29
225
  }
30
- next();
226
+ // Dev OR explicit opt-out → pass through (preserves previous behaviour).
227
+ if (!isProd || authDisabled) {
228
+ next();
229
+ return;
230
+ }
231
+ // Production WITHOUT an authorize hook → 503 with a hint.
232
+ if (!authorize) {
233
+ res.status(503).json({
234
+ error: "Trace endpoints require auth in production",
235
+ hint: "Register an authorize hook before listen() — `trigger.setTraceAuth(req => ...)` — or set BLOK_TRACE_AUTH_DISABLED=1 to opt out (typically because /__blok/* is already firewalled).",
236
+ docs: "https://github.com/deskree-inc/blok/blob/main/docs/d/security/cookbook.mdx#secure-the-trace-api-and-studio",
237
+ });
238
+ return;
239
+ }
240
+ // Production WITH an authorize hook → consult it. Wrap in
241
+ // `Promise.resolve().then(...)` so a SYNC throw inside the
242
+ // authorize function is caught the same as an async rejection.
243
+ Promise.resolve()
244
+ .then(() => authorize(req))
245
+ .then((ok) => {
246
+ if (ok) {
247
+ next();
248
+ }
249
+ else {
250
+ res.status(401).json({ error: "Unauthorized" });
251
+ }
252
+ })
253
+ .catch((err) => {
254
+ // Don't leak the underlying error message — log it once,
255
+ // return a generic 401.
256
+ console.error("[blok][trace-auth] authorize() threw:", err?.message ?? err);
257
+ res.status(401).json({ error: "Unauthorized" });
258
+ });
31
259
  });
32
260
  // === Utility Endpoints ===
33
261
  router.get("/health", (_req, res) => {
@@ -47,12 +275,43 @@ export function registerTraceRoutes(router, tracker) {
47
275
  // === Workflow Endpoints ===
48
276
  router.get("/workflows", (_req, res) => {
49
277
  const summaries = t.getWorkflowSummaries();
278
+ // E4 follow-up — `getWorkflowSummaries()` derives only from the
279
+ // `workflow_runs` table, so workflows that have been registered
280
+ // but never run don't show up. Studio uses this endpoint to power
281
+ // the sidebar; without merging the registry the user has no way
282
+ // to navigate to a workflow before it executes, including no way
283
+ // to see its static DAG. Synthesize a zero-stat summary for
284
+ // every registered workflow not already present.
285
+ const seen = new Set(summaries.map((s) => s.name));
286
+ for (const reg of WorkflowRegistry.getInstance().list()) {
287
+ if (seen.has(reg.name))
288
+ continue;
289
+ const synthesized = synthesizeRegistryOnlySummary(reg);
290
+ if (synthesized)
291
+ summaries.push(synthesized);
292
+ }
50
293
  res.json(summaries);
51
294
  });
52
295
  router.get("/workflows/:name", (req, res) => {
53
296
  const { name } = req.params;
54
297
  const summaries = t.getWorkflowSummaries();
55
- const summary = summaries.find((s) => s.name === name);
298
+ let summary = summaries.find((s) => s.name === name);
299
+ // E4 — surface the raw workflow JSON (pre-normalization) from
300
+ // the registry so Studio can render the static DAG. Triggers
301
+ // feed the registry at boot; if the workflow was registered
302
+ // inline (e.g. tests with no source file) it's still here.
303
+ const registered = WorkflowRegistry.getInstance().get(name);
304
+ // Sidebar follow-up (#99) — if the workflow is registered but
305
+ // has never run, `getWorkflowSummaries()` won't return a row
306
+ // for it (SQL aggregation derives from `workflow_runs`). Fall
307
+ // back to a synthesized zero-stat summary so Studio's detail
308
+ // page renders + the Graph tab AND the empty-state curl example
309
+ // are reachable on first sight.
310
+ if (!summary && registered) {
311
+ const synthesized = synthesizeRegistryOnlySummary(registered);
312
+ if (synthesized)
313
+ summary = synthesized;
314
+ }
56
315
  if (!summary) {
57
316
  res.status(404).json({ error: `Workflow '${name}' not found` });
58
317
  return;
@@ -69,12 +328,70 @@ export function registerTraceRoutes(router, tracker) {
69
328
  runtimes.add(node.runtimeKind);
70
329
  }
71
330
  }
331
+ // Sample-body resolution. Priority (highest first):
332
+ // 1. Author override: `trigger.http.examples.body` in the
333
+ // workflow JSON (#100). Source of truth — never overridden.
334
+ // 2. Recorded sample: captured from the first successful run
335
+ // when `trigger.http.recordSample: true` (option C / v0.6).
336
+ // Real-world body that exercised the workflow.
337
+ // 3. Static inference: walk step references for
338
+ // `ctx.request.body.<path>` (#100). Heuristic placeholder.
339
+ // 4. Empty `{}` — fallback when nothing else exists.
340
+ // The `inferSampleBody()` helper already handles #1 vs #3; we
341
+ // slot the recorded sample in between by overriding the `source`
342
+ // + `body` when one exists AND the helper didn't fall through to
343
+ // an author override.
344
+ const inferred = registered?.workflow ? inferSampleBody(registered.workflow) : null;
345
+ const recorded = t.getWorkflowSample(name);
346
+ let examples;
347
+ if (inferred?.source === "author") {
348
+ examples = { body: inferred.body, source: "author" };
349
+ }
350
+ else if (recorded) {
351
+ examples = { body: recorded.body, source: "recorded" };
352
+ }
353
+ else if (inferred) {
354
+ examples = { body: inferred.body, source: inferred.source };
355
+ }
72
356
  res.json({
73
357
  ...summary,
74
358
  nodeNames: Array.from(nodeNames),
75
359
  runtimes: Array.from(runtimes),
360
+ definition: registered?.workflow,
361
+ examples,
76
362
  });
77
363
  });
364
+ // E4 follow-up — surface boot-time route-build errors (collisions,
365
+ // missing paths) so Studio can render a banner on the Workflows page
366
+ // rather than burying the issue in terminal logs. The trigger
367
+ // populates `RoutingDiagnostics` at boot via `buildRouteTable` in
368
+ // tolerant mode.
369
+ router.get("/routing", (_req, res) => {
370
+ const diagnostics = RoutingDiagnostics.getInstance();
371
+ res.json({
372
+ diagnostics: diagnostics.list(),
373
+ count: diagnostics.count(),
374
+ now: Date.now(),
375
+ });
376
+ });
377
+ // #103 follow-up — operator-facing escape hatch for the
378
+ // first-record-wins semantic. When the captured body is wrong / stale
379
+ // / contains a one-off payload, deleting the sample lets the next
380
+ // successful run re-record (only if the workflow's HTTP trigger has
381
+ // `recordSample: true`). Studio's workflow detail page wires a
382
+ // "Re-record sample" button to this endpoint. Returns `{ deleted:
383
+ // true }` on success, `404` when no sample exists for the workflow.
384
+ // JSON-rather-than-204 keeps `fetchJson()` on the client side simple
385
+ // and mirrors the saved-filters DELETE shape.
386
+ router.delete("/workflows/:name/sample", (req, res) => {
387
+ const { name } = req.params;
388
+ const removed = t.deleteWorkflowSample(name);
389
+ if (!removed) {
390
+ res.status(404).json({ error: `No recorded sample for workflow '${name}'` });
391
+ return;
392
+ }
393
+ res.json({ deleted: true });
394
+ });
78
395
  router.get("/workflows/:name/runs", (req, res) => {
79
396
  const { name } = req.params;
80
397
  const status = req.query.status;
@@ -489,6 +806,21 @@ export function registerTraceRoutes(router, tracker) {
489
806
  const workflow = req.query.workflow;
490
807
  const status = req.query.status;
491
808
  const tags = req.query.tags ? req.query.tags.split(",").map((t) => t.trim()) : undefined;
809
+ // F2 (v0.5) — `metadata.<key>[__op]=<value>` query params parsed
810
+ // into a `MetadataFilter[]` for the RunQuery filter. Multiple
811
+ // pairs combine with AND semantics.
812
+ //
813
+ // Examples:
814
+ // metadata.tier=premium → {key: "tier", op: "eq", value: "premium"}
815
+ // metadata.tier__ne=free → {key: "tier", op: "ne", value: "free"}
816
+ // metadata.count__gt=10 → {key: "count", op: "gt", value: "10"}
817
+ // metadata.region__in=us,eu → {key: "region", op: "in", value: ["us","eu"]}
818
+ // metadata.name__like=test% → {key: "name", op: "like", value: "test%"}
819
+ //
820
+ // Keys are restricted by the SqliteRunStore implementation
821
+ // (`/^[a-zA-Z0-9_-]+$/`) for JSON path safety; non-matching keys
822
+ // or unknown operators silently drop.
823
+ const metadata = parseMetadataFiltersFromQuery(req.query);
492
824
  const limit = Number.parseInt(req.query.limit || "50", 10);
493
825
  const offset = Number.parseInt(req.query.offset || "0", 10);
494
826
  const sort = req.query.sort || "desc";
@@ -520,6 +852,7 @@ export function registerTraceRoutes(router, tracker) {
520
852
  workflow,
521
853
  status: status,
522
854
  tags,
855
+ metadata,
523
856
  limit: needsPostFilter ? Math.max(limit, 1000) : limit,
524
857
  offset: needsPostFilter ? 0 : offset,
525
858
  sort,
@@ -570,6 +903,21 @@ export function registerTraceRoutes(router, tracker) {
570
903
  const events = t.getEvents(runId, since);
571
904
  res.json(events);
572
905
  });
906
+ /**
907
+ * Tier 2 · sub-workflow lineage. Returns the runs that were started
908
+ * by `subworkflow:` steps inside the given parent run. Studio renders
909
+ * these as a "Sub-runs" list on the parent's run detail page.
910
+ */
911
+ router.get("/runs/:runId/subruns", (req, res) => {
912
+ const { runId } = req.params;
913
+ const run = t.getRun(runId);
914
+ if (!run) {
915
+ res.status(404).json({ error: `Run '${runId}' not found` });
916
+ return;
917
+ }
918
+ const subruns = t.getRunsByParent(runId);
919
+ res.json(subruns);
920
+ });
573
921
  router.delete("/runs", (_req, res) => {
574
922
  const deleted = t.clearAll();
575
923
  res.json({ deleted });
@@ -602,9 +950,18 @@ export function registerTraceRoutes(router, tracker) {
602
950
  const overrides = (req.body || {});
603
951
  const finalMethod = (overrides.method || method).toUpperCase();
604
952
  const finalUrl = overrides.path ? `${protocol}://${host}${overrides.path}` : url;
953
+ // Security review FW-2 — strip sensitive headers from overrides
954
+ // BEFORE merging, then layer the framework-controlled headers
955
+ // LAST so an attacker can't replace `X-Blok-Replay-Of`.
956
+ const safeOverrideHeaders = filterReplayHeaders(overrides.headers);
605
957
  const customHeaders = {
606
958
  "Content-Type": "application/json",
607
- ...(overrides.headers || {}),
959
+ ...safeOverrideHeaders,
960
+ // Tier 1 · replay lineage. TriggerBase reads this header and threads
961
+ // it into `tracker.startRun({ replayOf })`, which persists onto the
962
+ // new run's WorkflowRun.replayOf field. Studio renders a
963
+ // "Replay of #..." breadcrumb that links back to the source run.
964
+ "X-Blok-Replay-Of": runId,
608
965
  };
609
966
  const body = overrides.body !== undefined ? JSON.stringify(overrides.body) : undefined;
610
967
  // Listen for the next RUN_STARTED event matching this workflow
@@ -624,6 +981,10 @@ export function registerTraceRoutes(router, tracker) {
624
981
  newRunId: event.runId,
625
982
  originalRunId: runId,
626
983
  workflowName: run.workflowName,
984
+ // Tier 1 · explicit lineage in the API response so Studio
985
+ // doesn't have to fetch the new run separately to confirm
986
+ // the replay relationship.
987
+ replayOf: runId,
627
988
  });
628
989
  };
629
990
  t.on("RUN_STARTED", onRunStarted);
@@ -656,6 +1017,200 @@ export function registerTraceRoutes(router, tracker) {
656
1017
  // Cleanup if client disconnects
657
1018
  req.on("close", cleanup);
658
1019
  });
1020
+ // === Concurrency observability (Tier 2 follow-up) ===
1021
+ /**
1022
+ * Concurrency backend health probe. Returns the configured backend
1023
+ * (`"in-process"` when none) and basic state. Useful for k8s-style
1024
+ * health checks AND Studio's "Backend status" tile.
1025
+ *
1026
+ * GET /__blok/concurrency/health
1027
+ */
1028
+ router.get("/concurrency/health", (_req, res) => {
1029
+ const backend = t.getConcurrencyBackend();
1030
+ res.json({
1031
+ backend: backend?.name ?? "in-process",
1032
+ disabled: process.env.BLOK_CONCURRENCY_DISABLED === "1",
1033
+ leaseMs: process.env.BLOK_CONCURRENCY_LEASE_MS ? Number(process.env.BLOK_CONCURRENCY_LEASE_MS) : 60 * 60 * 1000,
1034
+ });
1035
+ });
1036
+ /**
1037
+ * Snapshot of currently in-flight concurrency slots, grouped by
1038
+ * (workflowName, concurrencyKey) bucket. Powers Studio's per-key
1039
+ * in-flight tile.
1040
+ *
1041
+ * GET /__blok/concurrency/state
1042
+ */
1043
+ router.get("/concurrency/state", (_req, res) => {
1044
+ const buckets = t.getStore().getConcurrencySnapshot(Date.now());
1045
+ const totalLeases = buckets.reduce((sum, b) => sum + b.leases.length, 0);
1046
+ res.json({
1047
+ totalBuckets: buckets.length,
1048
+ totalLeases,
1049
+ buckets: buckets.map((b) => ({
1050
+ workflowName: b.workflowName,
1051
+ concurrencyKey: b.concurrencyKey,
1052
+ inFlight: b.leases.length,
1053
+ leases: b.leases,
1054
+ })),
1055
+ });
1056
+ });
1057
+ /**
1058
+ * List pending scheduled dispatches — rows from `scheduled_dispatches`
1059
+ * that haven't fired yet (delayed / queued / debounced). Powers
1060
+ * Studio's "Scheduled runs" view (E1) so operators can see + cancel
1061
+ * inbound dispatches BEFORE they execute.
1062
+ *
1063
+ * Already-fired runs are pruned from this table the moment
1064
+ * `dispatchDeferred` re-enters; expired runs are pruned by the
1065
+ * Janitor sweep. To see those, use `/__blok/runs?status=expired` /
1066
+ * `?status=completed` / etc. against `workflow_runs`.
1067
+ *
1068
+ * GET /__blok/scheduled
1069
+ *
1070
+ * Query params:
1071
+ * - `status` — comma-separated list of `delayed`/`queued`/`debounced`.
1072
+ * When omitted, returns all three.
1073
+ * - `workflowName` — exact-match filter.
1074
+ * - `limit` — pagination cap (default 100, max 500).
1075
+ * - `offset` — pagination offset (default 0).
1076
+ *
1077
+ * Returns:
1078
+ * `{ rows: ScheduledDispatchRow[], total: number, now: number }`
1079
+ * `now` is the server-side `Date.now()` snapshot so the client can
1080
+ * render accurate "fires in 27s" countdowns without clock skew.
1081
+ */
1082
+ router.get("/scheduled", (req, res) => {
1083
+ const query = (req.query ?? {});
1084
+ const validStatuses = new Set(["delayed", "queued", "debounced"]);
1085
+ const requestedStatuses = (query.status ?? "")
1086
+ .split(",")
1087
+ .map((s) => s.trim())
1088
+ .filter((s) => s.length > 0 && validStatuses.has(s));
1089
+ const statusesToReturn = requestedStatuses.length > 0 ? requestedStatuses : ["delayed", "queued", "debounced"];
1090
+ // Pull each requested status separately — the underlying
1091
+ // `getScheduledDispatches({status})` only accepts a single status
1092
+ // string. Concatenate + sort by scheduledAt ASC so the soonest
1093
+ // next-fire is at the top of the table.
1094
+ const store = t.getStore();
1095
+ const allRows = statusesToReturn.flatMap((status) => store.getScheduledDispatches({ status: status }));
1096
+ const workflowFilter = typeof query.workflowName === "string" ? query.workflowName : undefined;
1097
+ const filtered = workflowFilter ? allRows.filter((r) => r.workflowName === workflowFilter) : allRows;
1098
+ filtered.sort((a, b) => a.scheduledAt - b.scheduledAt);
1099
+ // Pagination — the underlying store does full scans today; cap at
1100
+ // 500 so a runaway query can't pin the event loop.
1101
+ const limit = clampInt(query.limit, 1, 500, 100);
1102
+ const offset = clampInt(query.offset, 0, Number.MAX_SAFE_INTEGER, 0);
1103
+ const page = filtered.slice(offset, offset + limit);
1104
+ // Sensitive headers are already stripped at persist time
1105
+ // (see `extractDispatchPayload` in HttpTrigger). Re-strip on read
1106
+ // too as a belt-and-braces measure — operators should never see
1107
+ // `authorization` / `cookie` / `x-api-key` in the Studio UI.
1108
+ const sanitized = page.map((row) => ({ ...row, payload: sanitizeDispatchPayload(row.payload) }));
1109
+ res.json({
1110
+ rows: sanitized,
1111
+ total: filtered.length,
1112
+ now: Date.now(),
1113
+ });
1114
+ });
1115
+ // === Cancellation (Tier 2 polish) ===
1116
+ /**
1117
+ * Cancel a pending (delayed/debounced/queued) run before it executes.
1118
+ *
1119
+ * `POST /__blok/runs/:runId/cancel`
1120
+ *
1121
+ * Returns:
1122
+ * - `200 { cancelled: true, runId, previousStatus, newStatus: "cancelled" }` on success
1123
+ * - `400 { error }` when the run isn't in a cancellable state
1124
+ * (running/completed/failed/throttled/expired/crashed/timedOut/cancelled)
1125
+ * - `404 { error }` when the runId doesn't exist
1126
+ *
1127
+ * Cancels the underlying scheduler entry (`DeferredRunScheduler` for
1128
+ * delayed/queued runs; `DebounceCoordinator` for debounced trailing-mode
1129
+ * runs) AND flips the run's status to `"cancelled"` via
1130
+ * `tracker.cancelRun(runId)`. Both scheduler `.cancel()` methods are
1131
+ * idempotent so calling them on a runId that doesn't have a pending
1132
+ * timer is a safe no-op.
1133
+ */
1134
+ router.post("/runs/:runId/cancel", (req, res) => {
1135
+ const { runId } = req.params;
1136
+ const run = t.getRun(runId);
1137
+ if (!run) {
1138
+ res.status(404).json({ error: `Run '${runId}' not found` });
1139
+ return;
1140
+ }
1141
+ // Tier 2 follow-up · "running" added so cooperative AbortSignal
1142
+ // cancellation can flip in-flight runs to `cancelled` via
1143
+ // `tracker.abortRunningRun(runId)`. Other terminal states
1144
+ // (completed/failed/throttled/expired/crashed/timedOut) remain
1145
+ // non-cancellable.
1146
+ const cancellable = ["delayed", "debounced", "queued", "running"];
1147
+ if (!cancellable.includes(run.status)) {
1148
+ res.status(400).json({
1149
+ error: `Cannot cancel run in '${run.status}' state. Only runs in 'delayed', 'debounced', 'queued', or 'running' state can be cancelled.`,
1150
+ runId,
1151
+ status: run.status,
1152
+ });
1153
+ return;
1154
+ }
1155
+ // Capture previousStatus BEFORE cancelRun mutates the run record.
1156
+ const previousStatus = run.status;
1157
+ // Tier 2 follow-up · running runs use cooperative AbortSignal.
1158
+ // `abortRunningRun` fires the controller AND flips status via
1159
+ // cancelRun in one atomic-feeling call. Returns 200 — the
1160
+ // in-flight step's between-step check will throw shortly.
1161
+ if (run.status === "running") {
1162
+ const aborted = t.abortRunningRun(runId);
1163
+ if (!aborted) {
1164
+ // No registered controller — likely a stale state where
1165
+ // the run is mid-finalization. Still return success since
1166
+ // the run is on its way to terminal anyway.
1167
+ res.json({
1168
+ cancelled: true,
1169
+ runId,
1170
+ previousStatus,
1171
+ newStatus: "cancelled",
1172
+ note: "No active AbortController; run will reach terminal state naturally.",
1173
+ });
1174
+ return;
1175
+ }
1176
+ res.json({
1177
+ cancelled: true,
1178
+ runId,
1179
+ previousStatus,
1180
+ newStatus: "cancelled",
1181
+ note: "Cancellation initiated via AbortSignal; in-flight step will abort cooperatively.",
1182
+ });
1183
+ return;
1184
+ }
1185
+ // Best-effort scheduler cleanup (both methods are idempotent).
1186
+ DeferredRunScheduler.getInstance().cancel(runId);
1187
+ if (run.debounceKey) {
1188
+ // Tier C #1 — `cancel()` is now async because the coordinator may
1189
+ // route through a cross-process backend. Fire-and-forget: the
1190
+ // HTTP response shouldn't block on broker cleanup, and the
1191
+ // run-status flip below is the source of truth for the caller.
1192
+ void DebounceCoordinator.getInstance()
1193
+ .cancel(run.workflowName, run.debounceKey)
1194
+ .catch((err) => {
1195
+ console.warn(`[blok][scheduling] debounce cancel failed for ${run.workflowName}:${run.debounceKey}: ${err instanceof Error ? err.message : String(err)}`);
1196
+ });
1197
+ }
1198
+ const cancelled = t.cancelRun(runId);
1199
+ if (!cancelled) {
1200
+ // Race: status changed between our check and the call.
1201
+ res.status(409).json({
1202
+ error: `Could not cancel run '${runId}'. It may have just transitioned to a non-cancellable state.`,
1203
+ runId,
1204
+ });
1205
+ return;
1206
+ }
1207
+ res.json({
1208
+ cancelled: true,
1209
+ runId,
1210
+ previousStatus,
1211
+ newStatus: "cancelled",
1212
+ });
1213
+ });
659
1214
  // === AI Error Explanation ===
660
1215
  /**
661
1216
  * Explain a run or node error using an LLM.
@@ -841,6 +1396,57 @@ export function registerTraceRoutes(router, tracker) {
841
1396
  t.saveDashboard(copy);
842
1397
  res.status(201).json(copy);
843
1398
  });
1399
+ // === Saved filters (E2) ===
1400
+ /**
1401
+ * List every saved filter. Newest-updated first so the dropdown
1402
+ * surfaces recently-edited presets at the top.
1403
+ * GET /__blok/saved-filters
1404
+ */
1405
+ router.get("/saved-filters", (_req, res) => {
1406
+ res.json({ filters: t.listSavedFilters() });
1407
+ });
1408
+ /**
1409
+ * Upsert a saved filter. `name` is the unique key — re-posting with
1410
+ * the same name overwrites the existing entry (preserves `id` +
1411
+ * `createdAt`). Studio uses this to replace the localStorage
1412
+ * `saveFilter()` call.
1413
+ * POST /__blok/saved-filters
1414
+ * Body: { name, status, tagsInput, metadataInput }
1415
+ */
1416
+ router.post("/saved-filters", (req, res) => {
1417
+ const body = (req.body || {});
1418
+ const name = typeof body.name === "string" ? body.name.trim() : "";
1419
+ if (name.length === 0) {
1420
+ res.status(400).json({ error: "Saved-filter `name` is required" });
1421
+ return;
1422
+ }
1423
+ const now = Date.now();
1424
+ const persisted = t.upsertSavedFilter({
1425
+ id: `sf_${now.toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1426
+ name,
1427
+ status: typeof body.status === "string" ? body.status : "",
1428
+ tagsInput: typeof body.tagsInput === "string" ? body.tagsInput : "",
1429
+ metadataInput: typeof body.metadataInput === "string" ? body.metadataInput : "",
1430
+ createdAt: now,
1431
+ updatedAt: now,
1432
+ });
1433
+ res.status(201).json(persisted);
1434
+ });
1435
+ /**
1436
+ * Delete a saved filter by name. Returns `404` when the row didn't
1437
+ * exist (so the Studio mutation can disambiguate). Uses the name
1438
+ * (not the id) so the Studio component — which knows the name from
1439
+ * the dropdown — doesn't need a round-trip to learn the id first.
1440
+ * DELETE /__blok/saved-filters/:name
1441
+ */
1442
+ router.delete("/saved-filters/:name", (req, res) => {
1443
+ const removed = t.deleteSavedFilter(req.params.name);
1444
+ if (!removed) {
1445
+ res.status(404).json({ error: `Saved filter '${req.params.name}' not found` });
1446
+ return;
1447
+ }
1448
+ res.json({ deleted: true });
1449
+ });
844
1450
  // === SSE Endpoints ===
845
1451
  /**
846
1452
  * SSE stream for a specific run.