@voyantjs/workflows-node-step-container 0.107.5 → 0.107.7

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/dist/server.js CHANGED
@@ -19,6 +19,7 @@
19
19
  import { createHash } from "node:crypto";
20
20
  import { createServer } from "node:http";
21
21
  import { __resetRegistry, getWorkflow } from "@voyantjs/workflows";
22
+ import { createBundleUrlPolicy, createHmacBodyVerifier, createHmacSigner, parseTokenList, STEP_AUTH_HEADER, STEP_RESPONSE_AUTH_HEADER, } from "@voyantjs/workflows/auth";
22
23
  import { executeWorkflowStep, } from "@voyantjs/workflows/handler";
23
24
  /**
24
25
  * Sentinel thrown by the container's stepRunner after the target
@@ -38,6 +39,9 @@ const STOP_AFTER_TARGET = Symbol("STOP_AFTER_TARGET");
38
39
  */
39
40
  const loadedBundles = new Map();
40
41
  let activeBundleHash;
42
+ const bundleUrlAllowed = createBundleUrlPolicy({
43
+ allowedOrigins: parseTokenList(process.env.VOYANT_BUNDLE_ALLOWED_ORIGINS),
44
+ });
41
45
  function normalizeHash(hash) {
42
46
  return hash.replace(/^sha256:/i, "").toLowerCase();
43
47
  }
@@ -45,6 +49,10 @@ async function sha256Hex(bytes) {
45
49
  return createHash("sha256").update(bytes).digest("hex");
46
50
  }
47
51
  async function fetchBundle(bundle) {
52
+ const decision = bundleUrlAllowed(bundle.url);
53
+ if (!decision.ok) {
54
+ throw new Error(decision.message);
55
+ }
48
56
  const response = await fetch(bundle.url);
49
57
  if (!response.ok) {
50
58
  throw new Error(`bundle fetch returned HTTP ${response.status}`);
@@ -57,6 +65,23 @@ async function fetchBundle(bundle) {
57
65
  }
58
66
  return buf;
59
67
  }
68
+ let bodyVerifierPromise;
69
+ let responseSignerPromise;
70
+ async function verifyStepRequest(req, rawBody) {
71
+ const secret = process.env.VOYANT_WORKFLOW_STEP_AUTH_SECRET;
72
+ if (!secret) {
73
+ if (process.env.VOYANT_WORKFLOW_STEP_ALLOW_UNAUTHENTICATED === "1") {
74
+ console.warn("[node-step-container] AUTH DISABLED: VOYANT_WORKFLOW_STEP_ALLOW_UNAUTHENTICATED=1");
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ bodyVerifierPromise ??= createHmacBodyVerifier(secret);
80
+ const verifier = await bodyVerifierPromise;
81
+ const header = req.headers[STEP_AUTH_HEADER];
82
+ const signature = Array.isArray(header) ? header[0] : header;
83
+ return verifier(rawBody, signature);
84
+ }
60
85
  async function loadBundle(bundle) {
61
86
  const key = normalizeHash(bundle.hash);
62
87
  const existing = loadedBundles.get(key);
@@ -201,9 +226,6 @@ async function handleStep(payload) {
201
226
  workflowId: payload.workflowId,
202
227
  workflowVersion: payload.workflowVersion,
203
228
  input: payload.input,
204
- // The JournalSlice type in @voyantjs/workflows is a superset of our
205
- // structural interface; cast is safe because executeWorkflowStep
206
- // only reads standard fields.
207
229
  journal: journalSlice,
208
230
  invocationCount: 1,
209
231
  environment: {
@@ -269,6 +291,21 @@ function json(res, status, body) {
269
291
  });
270
292
  res.end(bytes);
271
293
  }
294
+ async function signedStepJson(res, status, body) {
295
+ const text = JSON.stringify(body);
296
+ const bytes = Buffer.from(text, "utf-8");
297
+ const secret = process.env.VOYANT_WORKFLOW_STEP_AUTH_SECRET;
298
+ const headers = {
299
+ "content-type": "application/json; charset=utf-8",
300
+ "content-length": String(bytes.byteLength),
301
+ };
302
+ if (secret) {
303
+ responseSignerPromise ??= createHmacSigner(secret);
304
+ headers[STEP_RESPONSE_AUTH_HEADER] = await (await responseSignerPromise)(text);
305
+ }
306
+ res.writeHead(status, headers);
307
+ res.end(bytes);
308
+ }
272
309
  async function main() {
273
310
  const port = Number(process.env.PORT ?? 8080);
274
311
  const server = createServer(async (req, res) => {
@@ -276,9 +313,14 @@ async function main() {
276
313
  json(res, 404, { error: "not_found" });
277
314
  return;
278
315
  }
316
+ const rawBody = await readBody(req);
317
+ if (!(await verifyStepRequest(req, rawBody))) {
318
+ json(res, 401, { error: "unauthorized" });
319
+ return;
320
+ }
279
321
  let payload;
280
322
  try {
281
- payload = JSON.parse(await readBody(req));
323
+ payload = JSON.parse(rawBody);
282
324
  }
283
325
  catch (err) {
284
326
  json(res, 400, { error: "invalid_json", message: String(err) });
@@ -286,7 +328,7 @@ async function main() {
286
328
  }
287
329
  try {
288
330
  const entry = await handleStep(payload);
289
- json(res, 200, entry);
331
+ await signedStepJson(res, 200, entry);
290
332
  }
291
333
  catch (err) {
292
334
  json(res, 500, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyantjs/workflows-node-step-container",
3
- "version": "0.107.5",
3
+ "version": "0.107.7",
4
4
  "description": "Reference Node container image for executing `runtime: \"node\"` steps. Deployed alongside the orchestrator as a Cloudflare Container.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "voyant-workflows-node-step-container": "./dist/server.js"
31
31
  },
32
32
  "dependencies": {
33
- "@voyantjs/workflows": "^0.107.5"
33
+ "@voyantjs/workflows": "^0.107.7"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^20.12.0",
package/src/server.ts CHANGED
@@ -20,6 +20,14 @@
20
20
  import { createHash } from "node:crypto"
21
21
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
22
22
  import { __resetRegistry, getWorkflow } from "@voyantjs/workflows"
23
+ import {
24
+ createBundleUrlPolicy,
25
+ createHmacBodyVerifier,
26
+ createHmacSigner,
27
+ parseTokenList,
28
+ STEP_AUTH_HEADER,
29
+ STEP_RESPONSE_AUTH_HEADER,
30
+ } from "@voyantjs/workflows/auth"
23
31
  import {
24
32
  type ExecuteWorkflowStepRequest,
25
33
  executeWorkflowStep,
@@ -32,13 +40,7 @@ interface BundleLocation {
32
40
  hash: string
33
41
  }
34
42
 
35
- interface JournalSlice {
36
- stepResults: Record<string, unknown>
37
- waitpointsResolved: Record<string, unknown>
38
- compensationsRun: Record<string, unknown>
39
- metadataState: Record<string, unknown>
40
- streamsCompleted: Record<string, unknown>
41
- }
43
+ type JournalSlice = ExecuteWorkflowStepRequest["journal"]
42
44
 
43
45
  interface StepDispatchPayload {
44
46
  runId: string
@@ -74,6 +76,9 @@ const STOP_AFTER_TARGET = Symbol("STOP_AFTER_TARGET")
74
76
  */
75
77
  const loadedBundles = new Map<string, Promise<void>>()
76
78
  let activeBundleHash: string | undefined
79
+ const bundleUrlAllowed = createBundleUrlPolicy({
80
+ allowedOrigins: parseTokenList(process.env.VOYANT_BUNDLE_ALLOWED_ORIGINS),
81
+ })
77
82
 
78
83
  function normalizeHash(hash: string): string {
79
84
  return hash.replace(/^sha256:/i, "").toLowerCase()
@@ -84,6 +89,10 @@ async function sha256Hex(bytes: Uint8Array): Promise<string> {
84
89
  }
85
90
 
86
91
  async function fetchBundle(bundle: BundleLocation): Promise<Uint8Array> {
92
+ const decision = bundleUrlAllowed(bundle.url)
93
+ if (!decision.ok) {
94
+ throw new Error(decision.message)
95
+ }
87
96
  const response = await fetch(bundle.url)
88
97
  if (!response.ok) {
89
98
  throw new Error(`bundle fetch returned HTTP ${response.status}`)
@@ -97,6 +106,29 @@ async function fetchBundle(bundle: BundleLocation): Promise<Uint8Array> {
97
106
  return buf
98
107
  }
99
108
 
109
+ let bodyVerifierPromise:
110
+ | Promise<(body: string, signature: string | null | undefined) => Promise<boolean>>
111
+ | undefined
112
+ let responseSignerPromise: Promise<(body: string) => Promise<string>> | undefined
113
+
114
+ async function verifyStepRequest(req: IncomingMessage, rawBody: string): Promise<boolean> {
115
+ const secret = process.env.VOYANT_WORKFLOW_STEP_AUTH_SECRET
116
+ if (!secret) {
117
+ if (process.env.VOYANT_WORKFLOW_STEP_ALLOW_UNAUTHENTICATED === "1") {
118
+ console.warn(
119
+ "[node-step-container] AUTH DISABLED: VOYANT_WORKFLOW_STEP_ALLOW_UNAUTHENTICATED=1",
120
+ )
121
+ return true
122
+ }
123
+ return false
124
+ }
125
+ bodyVerifierPromise ??= createHmacBodyVerifier(secret)
126
+ const verifier = await bodyVerifierPromise
127
+ const header = req.headers[STEP_AUTH_HEADER]
128
+ const signature = Array.isArray(header) ? header[0] : header
129
+ return verifier(rawBody, signature)
130
+ }
131
+
100
132
  async function loadBundle(bundle: BundleLocation): Promise<void> {
101
133
  const key = normalizeHash(bundle.hash)
102
134
  const existing = loadedBundles.get(key)
@@ -241,10 +273,7 @@ async function handleStep(payload: StepDispatchPayload): Promise<StepJournalEntr
241
273
  workflowId: payload.workflowId,
242
274
  workflowVersion: payload.workflowVersion,
243
275
  input: payload.input,
244
- // The JournalSlice type in @voyantjs/workflows is a superset of our
245
- // structural interface; cast is safe because executeWorkflowStep
246
- // only reads standard fields.
247
- journal: journalSlice as unknown as ExecuteWorkflowStepRequest["journal"],
276
+ journal: journalSlice,
248
277
  invocationCount: 1,
249
278
  environment: {
250
279
  run: {
@@ -312,6 +341,22 @@ function json(res: ServerResponse, status: number, body: unknown): void {
312
341
  res.end(bytes)
313
342
  }
314
343
 
344
+ async function signedStepJson(res: ServerResponse, status: number, body: unknown): Promise<void> {
345
+ const text = JSON.stringify(body)
346
+ const bytes = Buffer.from(text, "utf-8")
347
+ const secret = process.env.VOYANT_WORKFLOW_STEP_AUTH_SECRET
348
+ const headers: Record<string, string> = {
349
+ "content-type": "application/json; charset=utf-8",
350
+ "content-length": String(bytes.byteLength),
351
+ }
352
+ if (secret) {
353
+ responseSignerPromise ??= createHmacSigner(secret)
354
+ headers[STEP_RESPONSE_AUTH_HEADER] = await (await responseSignerPromise)(text)
355
+ }
356
+ res.writeHead(status, headers)
357
+ res.end(bytes)
358
+ }
359
+
315
360
  async function main(): Promise<void> {
316
361
  const port = Number(process.env.PORT ?? 8080)
317
362
  const server = createServer(async (req, res) => {
@@ -319,16 +364,22 @@ async function main(): Promise<void> {
319
364
  json(res, 404, { error: "not_found" })
320
365
  return
321
366
  }
367
+ const rawBody = await readBody(req)
368
+ if (!(await verifyStepRequest(req, rawBody))) {
369
+ json(res, 401, { error: "unauthorized" })
370
+ return
371
+ }
372
+
322
373
  let payload: StepDispatchPayload
323
374
  try {
324
- payload = JSON.parse(await readBody(req)) as StepDispatchPayload
375
+ payload = JSON.parse(rawBody) as StepDispatchPayload
325
376
  } catch (err) {
326
377
  json(res, 400, { error: "invalid_json", message: String(err) })
327
378
  return
328
379
  }
329
380
  try {
330
381
  const entry = await handleStep(payload)
331
- json(res, 200, entry)
382
+ await signedStepJson(res, 200, entry)
332
383
  } catch (err) {
333
384
  json(res, 500, {
334
385
  error: "container_failure",