@voyantjs/workflows-node-step-container 0.107.4 → 0.107.6
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 +47 -5
- package/package.json +2 -2
- package/src/server.ts +64 -13
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(
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.107.6",
|
|
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.
|
|
33
|
+
"@voyantjs/workflows": "^0.107.6"
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
382
|
+
await signedStepJson(res, 200, entry)
|
|
332
383
|
} catch (err) {
|
|
333
384
|
json(res, 500, {
|
|
334
385
|
error: "container_failure",
|