@voyant-travel/workflows 0.107.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +52 -0
- package/README.md +79 -0
- package/dist/auth/index.d.ts +125 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +352 -0
- package/dist/bindings.d.ts +119 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +19 -0
- package/dist/client.d.ts +135 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +305 -0
- package/dist/conditions.d.ts +29 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/conditions.js +5 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +7 -0
- package/dist/driver.d.ts +237 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +53 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +76 -0
- package/dist/events/compile.d.ts +34 -0
- package/dist/events/compile.d.ts.map +1 -0
- package/dist/events/compile.js +204 -0
- package/dist/events/index.d.ts +8 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +11 -0
- package/dist/events/input-mapper.d.ts +24 -0
- package/dist/events/input-mapper.d.ts.map +1 -0
- package/dist/events/input-mapper.js +169 -0
- package/dist/events/manifest-builder.d.ts +42 -0
- package/dist/events/manifest-builder.d.ts.map +1 -0
- package/dist/events/manifest-builder.js +313 -0
- package/dist/events/payload-hash.d.ts +46 -0
- package/dist/events/payload-hash.d.ts.map +1 -0
- package/dist/events/payload-hash.js +98 -0
- package/dist/events/predicate.d.ts +77 -0
- package/dist/events/predicate.d.ts.map +1 -0
- package/dist/events/predicate.js +347 -0
- package/dist/events/registry.d.ts +37 -0
- package/dist/events/registry.d.ts.map +1 -0
- package/dist/events/registry.js +47 -0
- package/dist/handler/index.d.ts +114 -0
- package/dist/handler/index.d.ts.map +1 -0
- package/dist/handler/index.js +267 -0
- package/dist/handler/resume.d.ts +41 -0
- package/dist/handler/resume.d.ts.map +1 -0
- package/dist/handler/resume.js +44 -0
- package/dist/http-ingest.d.ts +54 -0
- package/dist/http-ingest.d.ts.map +1 -0
- package/dist/http-ingest.js +214 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/protocol/index.d.ts +345 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +110 -0
- package/dist/rate-limit/index.d.ts +40 -0
- package/dist/rate-limit/index.d.ts.map +1 -0
- package/dist/rate-limit/index.js +139 -0
- package/dist/runtime/ctx.d.ts +111 -0
- package/dist/runtime/ctx.d.ts.map +1 -0
- package/dist/runtime/ctx.js +624 -0
- package/dist/runtime/determinism.d.ts +19 -0
- package/dist/runtime/determinism.d.ts.map +1 -0
- package/dist/runtime/determinism.js +61 -0
- package/dist/runtime/errors.d.ts +21 -0
- package/dist/runtime/errors.d.ts.map +1 -0
- package/dist/runtime/errors.js +45 -0
- package/dist/runtime/executor.d.ts +166 -0
- package/dist/runtime/executor.d.ts.map +1 -0
- package/dist/runtime/executor.js +226 -0
- package/dist/runtime/journal.d.ts +56 -0
- package/dist/runtime/journal.d.ts.map +1 -0
- package/dist/runtime/journal.js +28 -0
- package/dist/testing/index.d.ts +117 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +599 -0
- package/dist/trigger.d.ts +37 -0
- package/dist/trigger.d.ts.map +1 -0
- package/dist/trigger.js +11 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/workflow.d.ts +222 -0
- package/dist/workflow.d.ts.map +1 -0
- package/dist/workflow.js +55 -0
- package/package.json +120 -0
- package/src/auth/index.ts +398 -0
- package/src/bindings.ts +135 -0
- package/src/client.ts +498 -0
- package/src/conditions.ts +43 -0
- package/src/config.ts +114 -0
- package/src/driver.ts +277 -0
- package/src/errors.ts +109 -0
- package/src/events/compile.ts +268 -0
- package/src/events/index.ts +42 -0
- package/src/events/input-mapper.ts +201 -0
- package/src/events/manifest-builder.ts +372 -0
- package/src/events/payload-hash.ts +110 -0
- package/src/events/predicate.ts +390 -0
- package/src/events/registry.ts +86 -0
- package/src/handler/index.ts +413 -0
- package/src/handler/resume.ts +100 -0
- package/src/http-ingest.ts +299 -0
- package/src/index.ts +18 -0
- package/src/protocol/index.ts +483 -0
- package/src/rate-limit/index.ts +181 -0
- package/src/runtime/ctx.ts +876 -0
- package/src/runtime/determinism.ts +75 -0
- package/src/runtime/errors.ts +58 -0
- package/src/runtime/executor.ts +442 -0
- package/src/runtime/journal.ts +80 -0
- package/src/testing/index.ts +796 -0
- package/src/trigger.ts +63 -0
- package/src/types.ts +80 -0
- package/src/workflow.ts +328 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// @voyant-travel/workflows/auth
|
|
2
|
+
//
|
|
3
|
+
// Paired HMAC signer + verifier for the `X-Voyant-Dispatch-Auth`
|
|
4
|
+
// header on `POST /__voyant/workflow-step`. Both sides share a
|
|
5
|
+
// symmetric secret — suitable for local dev and single-region
|
|
6
|
+
// deployments; asymmetric signing (control-plane issuer + tenant
|
|
7
|
+
// public-key) is a later upgrade that keeps the same header shape.
|
|
8
|
+
//
|
|
9
|
+
// Built on Web Crypto (`crypto.subtle`), so it works unchanged in
|
|
10
|
+
// Node 20+, Cloudflare Workers, Deno, Bun, and browsers.
|
|
11
|
+
//
|
|
12
|
+
// Usage on the orchestrator side:
|
|
13
|
+
//
|
|
14
|
+
// import { createHmacSigner } from "@voyant-travel/workflows/auth";
|
|
15
|
+
// const sign = await createHmacSigner(process.env.VOYANT_SIGNING_KEY!);
|
|
16
|
+
// createDispatchStepHandler(script, { dispatcher, sign });
|
|
17
|
+
//
|
|
18
|
+
// Usage on the tenant side:
|
|
19
|
+
//
|
|
20
|
+
// import { createHmacVerifier } from "@voyant-travel/workflows/auth";
|
|
21
|
+
// import { createStepHandler } from "@voyant-travel/workflows/handler";
|
|
22
|
+
// const verify = await createHmacVerifier(env.VOYANT_SIGNING_KEY);
|
|
23
|
+
// export default { fetch: createStepHandler({ verifyRequest: verify }) };
|
|
24
|
+
|
|
25
|
+
export const AUTH_HEADER = "x-voyant-dispatch-auth" as const
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HMAC header carried on orchestrator → node-step-container dispatches
|
|
29
|
+
* (`POST /step`). Signed over the raw request body with the shared
|
|
30
|
+
* `VOYANT_WORKFLOW_STEP_AUTH_SECRET`. See `createCfContainerStepRunner`
|
|
31
|
+
* (signing side) and `apps/workflows-node-step-container` (verifying side).
|
|
32
|
+
*/
|
|
33
|
+
export const STEP_AUTH_HEADER = "x-voyant-step-auth" as const
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* HMAC header returned by the node-step-container on successful `/step`
|
|
37
|
+
* responses. Signed over the raw JSON response body with the same
|
|
38
|
+
* `VOYANT_WORKFLOW_STEP_AUTH_SECRET` so the orchestrator can reject
|
|
39
|
+
* forged step journal entries before committing run state.
|
|
40
|
+
*/
|
|
41
|
+
export const STEP_RESPONSE_AUTH_HEADER = "x-voyant-step-response-auth" as const
|
|
42
|
+
|
|
43
|
+
/** Parse a comma-separated token/origin list env var into trimmed, non-empty entries. */
|
|
44
|
+
export function parseTokenList(raw: string | null | undefined): string[] {
|
|
45
|
+
return (raw ?? "")
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter((s) => s.length > 0)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns a verifier that accepts `Authorization: Bearer <token>`
|
|
53
|
+
* where `<token>` matches any of the `validTokens` (case-sensitive,
|
|
54
|
+
* constant-time compared). Usable as the `verifyRequest` dep on
|
|
55
|
+
* `handleWorkerRequest` / `createStepHandler` for the public-facing
|
|
56
|
+
* surface of an orchestrator or tenant Worker.
|
|
57
|
+
*
|
|
58
|
+
* Intended for dev + single-tenant deployments. Production should
|
|
59
|
+
* issue per-tenant, short-lived tokens from a control plane.
|
|
60
|
+
*/
|
|
61
|
+
export function createBearerVerifier(validTokens: readonly string[]): (req: Request) => void {
|
|
62
|
+
if (validTokens.length === 0) {
|
|
63
|
+
throw new Error("createBearerVerifier: need at least one valid token")
|
|
64
|
+
}
|
|
65
|
+
return (req) => {
|
|
66
|
+
const header = req.headers.get("authorization")
|
|
67
|
+
if (!header) throw new Error("missing Authorization header")
|
|
68
|
+
const match = /^Bearer (.+)$/.exec(header)
|
|
69
|
+
if (!match) {
|
|
70
|
+
throw new Error("Authorization header must use the Bearer scheme")
|
|
71
|
+
}
|
|
72
|
+
const presented = match[1]!
|
|
73
|
+
for (const valid of validTokens) {
|
|
74
|
+
if (constantTimeEquals(presented, valid)) return
|
|
75
|
+
}
|
|
76
|
+
throw new Error("bearer token does not match any configured value")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Constant-time string comparison. Does NOT early-return on length
|
|
82
|
+
* mismatch: the loop always runs over the longer input (out-of-range
|
|
83
|
+
* `charCodeAt` is NaN, which coerces to 0 under bitwise ops), and a
|
|
84
|
+
* length mismatch only sets a diff bit. This avoids leaking how many
|
|
85
|
+
* leading characters of a secret matched, or whether the length matched.
|
|
86
|
+
*/
|
|
87
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
88
|
+
const length = Math.max(a.length, b.length, 1)
|
|
89
|
+
let diff = a.length === b.length ? 0 : 1
|
|
90
|
+
for (let i = 0; i < length; i++) {
|
|
91
|
+
diff |= (a.charCodeAt(i) | 0) ^ (b.charCodeAt(i) | 0)
|
|
92
|
+
}
|
|
93
|
+
return diff === 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- Fail-closed bearer auth resolution ----
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Error thrown by verifiers produced in this module. Carries an HTTP
|
|
100
|
+
* `status` + machine-readable `code` so HTTP surfaces
|
|
101
|
+
* (`handleWorkerRequest`, the node dashboard server, step handlers)
|
|
102
|
+
* can map auth failures to the right response instead of a blanket 401.
|
|
103
|
+
*/
|
|
104
|
+
export class RequestAuthError extends Error {
|
|
105
|
+
readonly status: number
|
|
106
|
+
readonly code: string
|
|
107
|
+
|
|
108
|
+
constructor(status: number, code: string, message: string) {
|
|
109
|
+
super(message)
|
|
110
|
+
this.name = "RequestAuthError"
|
|
111
|
+
this.status = status
|
|
112
|
+
this.code = code
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type BearerAuthDecision =
|
|
117
|
+
| { ok: true }
|
|
118
|
+
| { ok: false; status: number; error: string; message: string }
|
|
119
|
+
|
|
120
|
+
export interface BearerAuthOptions {
|
|
121
|
+
/** Accepted bearer tokens. Empty/undefined = no auth configured. */
|
|
122
|
+
tokens?: readonly string[]
|
|
123
|
+
/**
|
|
124
|
+
* Explicit local-dev opt-out. When `true` AND no tokens are
|
|
125
|
+
* configured, requests are allowed through with a loud warning.
|
|
126
|
+
* When `false`/unset and no tokens are configured, every request is
|
|
127
|
+
* rejected with 503 `auth_not_configured` (fail closed).
|
|
128
|
+
*/
|
|
129
|
+
allowUnauthenticated?: boolean
|
|
130
|
+
/** Warning sink for the unauthenticated opt-out. Defaults to `console.warn`. */
|
|
131
|
+
warn?: (message: string) => void
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const UNAUTHENTICATED_WARNING =
|
|
135
|
+
"[voyant-workflows] AUTH DISABLED: no bearer tokens are configured and " +
|
|
136
|
+
"VOYANT_WORKFLOWS_ALLOW_UNAUTHENTICATED is set. Every caller can trigger, read, " +
|
|
137
|
+
"resume, and cancel workflow runs. This mode is for LOCAL DEVELOPMENT ONLY — " +
|
|
138
|
+
"configure VOYANT_API_TOKENS before deploying."
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a transport-agnostic bearer authorizer from a raw
|
|
142
|
+
* `Authorization` header value. Fail-closed semantics:
|
|
143
|
+
*
|
|
144
|
+
* - tokens configured → exact (constant-time) Bearer match or 401
|
|
145
|
+
* - no tokens + opt-out → always allowed (warns loudly once, at creation)
|
|
146
|
+
* - no tokens, no opt-out → always 503 `auth_not_configured`
|
|
147
|
+
*/
|
|
148
|
+
export function createBearerAuthorizer(
|
|
149
|
+
opts: BearerAuthOptions,
|
|
150
|
+
): (authorizationHeader: string | null | undefined) => BearerAuthDecision {
|
|
151
|
+
const tokens = (opts.tokens ?? []).filter((t) => t.length > 0)
|
|
152
|
+
if (tokens.length === 0) {
|
|
153
|
+
if (opts.allowUnauthenticated) {
|
|
154
|
+
;(opts.warn ?? console.warn)(UNAUTHENTICATED_WARNING)
|
|
155
|
+
return () => ({ ok: true })
|
|
156
|
+
}
|
|
157
|
+
return () => ({
|
|
158
|
+
ok: false,
|
|
159
|
+
status: 503,
|
|
160
|
+
error: "auth_not_configured",
|
|
161
|
+
message:
|
|
162
|
+
"no bearer tokens are configured for this workflows API; set VOYANT_API_TOKENS " +
|
|
163
|
+
"(or pass tokens explicitly), or opt out for local development with " +
|
|
164
|
+
"VOYANT_WORKFLOWS_ALLOW_UNAUTHENTICATED=1",
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
return (header) => {
|
|
168
|
+
if (!header) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
status: 401,
|
|
172
|
+
error: "unauthorized",
|
|
173
|
+
message: "missing Authorization header",
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const match = /^Bearer (.+)$/.exec(header)
|
|
177
|
+
if (!match) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
status: 401,
|
|
181
|
+
error: "unauthorized",
|
|
182
|
+
message: "Authorization header must use the Bearer scheme",
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const presented = match[1]!
|
|
186
|
+
for (const valid of tokens) {
|
|
187
|
+
if (constantTimeEquals(presented, valid)) return { ok: true }
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
status: 401,
|
|
192
|
+
error: "unauthorized",
|
|
193
|
+
message: "bearer token does not match any configured value",
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a `verifyRequest` hook (the dep consumed by
|
|
200
|
+
* `handleWorkerRequest` / `createStepHandler`) with fail-closed
|
|
201
|
+
* defaults — the missing-token case yields a verifier that rejects
|
|
202
|
+
* every request with a 503 `auth_not_configured` `RequestAuthError`
|
|
203
|
+
* instead of `undefined` (which would skip auth entirely).
|
|
204
|
+
*
|
|
205
|
+
* Returns `undefined` (auth skipped) ONLY when no tokens are
|
|
206
|
+
* configured AND `allowUnauthenticated` is explicitly set.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveRequestVerifier(
|
|
209
|
+
opts: BearerAuthOptions,
|
|
210
|
+
): ((req: Request) => void) | undefined {
|
|
211
|
+
const tokens = (opts.tokens ?? []).filter((t) => t.length > 0)
|
|
212
|
+
if (tokens.length === 0 && opts.allowUnauthenticated) {
|
|
213
|
+
;(opts.warn ?? console.warn)(UNAUTHENTICATED_WARNING)
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
const authorize = createBearerAuthorizer({ ...opts, allowUnauthenticated: false })
|
|
217
|
+
return (req) => {
|
|
218
|
+
const decision = authorize(req.headers.get("authorization"))
|
|
219
|
+
if (!decision.ok) {
|
|
220
|
+
throw new RequestAuthError(decision.status, decision.error, decision.message)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Returns a signer: `(body: string) => Promise<string>` (base64 HMAC-SHA256). */
|
|
226
|
+
export async function createHmacSigner(secret: string): Promise<(body: string) => Promise<string>> {
|
|
227
|
+
const key = await importKey(secret, ["sign"])
|
|
228
|
+
return async (body) => {
|
|
229
|
+
const sig = await crypto.subtle.sign("HMAC", key, encode(body))
|
|
230
|
+
return toBase64(sig)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Returns a verifier: `(req: Request) => Promise<void>`. Throws if:
|
|
236
|
+
* - the header is missing,
|
|
237
|
+
* - the signature is malformed,
|
|
238
|
+
* - the signature does not match the current body.
|
|
239
|
+
*
|
|
240
|
+
* The verifier consumes `req.body` via `req.text()`. Callers that
|
|
241
|
+
* need the body downstream should pre-clone: `req.clone()` before
|
|
242
|
+
* passing in.
|
|
243
|
+
*/
|
|
244
|
+
export async function createHmacVerifier(secret: string): Promise<(req: Request) => Promise<void>> {
|
|
245
|
+
const key = await importKey(secret, ["verify"])
|
|
246
|
+
return async (req) => {
|
|
247
|
+
const header = req.headers.get(AUTH_HEADER)
|
|
248
|
+
if (!header) {
|
|
249
|
+
throw new Error(`missing ${AUTH_HEADER} header`)
|
|
250
|
+
}
|
|
251
|
+
let sig: ArrayBuffer
|
|
252
|
+
try {
|
|
253
|
+
sig = fromBase64(header)
|
|
254
|
+
} catch {
|
|
255
|
+
throw new Error(`malformed ${AUTH_HEADER} header (expected base64)`)
|
|
256
|
+
}
|
|
257
|
+
const body = await req.clone().text()
|
|
258
|
+
const ok = await crypto.subtle.verify("HMAC", key, sig, encode(body))
|
|
259
|
+
if (!ok) {
|
|
260
|
+
throw new Error(`${AUTH_HEADER} signature does not match request body`)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Returns a verifier over a raw body string + detached base64 HMAC
|
|
267
|
+
* signature (the value of `STEP_AUTH_HEADER`). Suitable for servers
|
|
268
|
+
* that have already buffered the request body (e.g. the node step
|
|
269
|
+
* container). `crypto.subtle.verify` is constant-time; malformed
|
|
270
|
+
* base64 or a missing signature simply yields `false`.
|
|
271
|
+
*/
|
|
272
|
+
export async function createHmacBodyVerifier(
|
|
273
|
+
secret: string,
|
|
274
|
+
): Promise<(body: string, signatureBase64: string | null | undefined) => Promise<boolean>> {
|
|
275
|
+
const key = await importKey(secret, ["verify"])
|
|
276
|
+
return async (body, signatureBase64) => {
|
|
277
|
+
if (!signatureBase64) return false
|
|
278
|
+
let sig: ArrayBuffer
|
|
279
|
+
try {
|
|
280
|
+
sig = fromBase64(signatureBase64)
|
|
281
|
+
} catch {
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
284
|
+
return crypto.subtle.verify("HMAC", key, sig, encode(body))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- Bundle URL origin policy ----
|
|
289
|
+
|
|
290
|
+
export type BundleUrlDecision = { ok: true } | { ok: false; message: string }
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Default bundle source: Cloudflare R2's S3 endpoint
|
|
294
|
+
* (`<account>.r2.cloudflarestorage.com`), which is where deploy
|
|
295
|
+
* pipelines stage `container.mjs` and the orchestrator mints
|
|
296
|
+
* short-lived signed URLs. Anything else must be explicitly
|
|
297
|
+
* allowlisted via `allowedOrigins`.
|
|
298
|
+
*/
|
|
299
|
+
const R2_HOST_SUFFIX = ".r2.cloudflarestorage.com"
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Build an origin allowlist check for caller-supplied `bundle.url`
|
|
303
|
+
* values (the node step container dynamically `import()`s the fetched
|
|
304
|
+
* bytes, so an unrestricted URL is remote-code-execution-adjacent —
|
|
305
|
+
* the SHA-256 hash pin is supplied by the same caller and is not a
|
|
306
|
+
* security control on its own).
|
|
307
|
+
*
|
|
308
|
+
* - `allowedOrigins` set (e.g. from `VOYANT_BUNDLE_ALLOWED_ORIGINS`):
|
|
309
|
+
* the URL's origin must match one of the entries exactly.
|
|
310
|
+
* Invalid entries throw at creation time (misconfiguration should
|
|
311
|
+
* fail loudly, not silently widen).
|
|
312
|
+
* - unset/empty: only HTTPS URLs on `*.r2.cloudflarestorage.com`
|
|
313
|
+
* are allowed — the production bundle source.
|
|
314
|
+
*/
|
|
315
|
+
export function createBundleUrlPolicy(opts?: {
|
|
316
|
+
allowedOrigins?: readonly string[]
|
|
317
|
+
}): (url: string) => BundleUrlDecision {
|
|
318
|
+
const entries = (opts?.allowedOrigins ?? []).filter((entry) => entry.length > 0)
|
|
319
|
+
const allowed = new Set<string>()
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
let parsed: URL
|
|
322
|
+
try {
|
|
323
|
+
parsed = new URL(entry)
|
|
324
|
+
} catch {
|
|
325
|
+
throw new Error(`createBundleUrlPolicy: invalid origin in allowlist: "${entry}"`)
|
|
326
|
+
}
|
|
327
|
+
allowed.add(parsed.origin)
|
|
328
|
+
}
|
|
329
|
+
return (url) => {
|
|
330
|
+
let parsed: URL
|
|
331
|
+
try {
|
|
332
|
+
parsed = new URL(url)
|
|
333
|
+
} catch {
|
|
334
|
+
return { ok: false, message: `bundle url is not a valid URL: "${url}"` }
|
|
335
|
+
}
|
|
336
|
+
if (allowed.size > 0) {
|
|
337
|
+
if (allowed.has(parsed.origin)) return { ok: true }
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
message:
|
|
341
|
+
`bundle url origin "${parsed.origin}" is not in the configured allowlist ` +
|
|
342
|
+
"(VOYANT_BUNDLE_ALLOWED_ORIGINS)",
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (parsed.protocol === "https:" && parsed.hostname.endsWith(R2_HOST_SUFFIX)) {
|
|
346
|
+
return { ok: true }
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
message:
|
|
351
|
+
`bundle url origin "${parsed.origin}" rejected: with no VOYANT_BUNDLE_ALLOWED_ORIGINS ` +
|
|
352
|
+
`configured, only https://*${R2_HOST_SUFFIX} bundle sources are allowed`,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- Internals ----
|
|
358
|
+
|
|
359
|
+
async function importKey(
|
|
360
|
+
secret: string,
|
|
361
|
+
usages: readonly ("sign" | "verify")[],
|
|
362
|
+
): Promise<CryptoKey> {
|
|
363
|
+
if (secret.length === 0) {
|
|
364
|
+
throw new Error("HMAC secret must be a non-empty string")
|
|
365
|
+
}
|
|
366
|
+
return crypto.subtle.importKey("raw", encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
367
|
+
...usages,
|
|
368
|
+
])
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Encode to a freshly-allocated ArrayBuffer. TextEncoder's Uint8Array
|
|
373
|
+
* is typed as `Uint8Array<ArrayBufferLike>` under recent TS lib, which
|
|
374
|
+
* doesn't satisfy the `BufferSource` param of `subtle.sign/verify`.
|
|
375
|
+
* Copying into a new ArrayBuffer sidesteps the nominal mismatch.
|
|
376
|
+
*/
|
|
377
|
+
function encode(s: string): ArrayBuffer {
|
|
378
|
+
const view = new TextEncoder().encode(s)
|
|
379
|
+
const buf = new ArrayBuffer(view.byteLength)
|
|
380
|
+
new Uint8Array(buf).set(view)
|
|
381
|
+
return buf
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function toBase64(buffer: ArrayBuffer): string {
|
|
385
|
+
// btoa is available in every modern runtime (Node 16+, Workers, browsers).
|
|
386
|
+
const bytes = new Uint8Array(buffer)
|
|
387
|
+
let bin = ""
|
|
388
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!)
|
|
389
|
+
return btoa(bin)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function fromBase64(s: string): ArrayBuffer {
|
|
393
|
+
const bin = atob(s)
|
|
394
|
+
const buf = new ArrayBuffer(bin.length)
|
|
395
|
+
const view = new Uint8Array(buf)
|
|
396
|
+
for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i)
|
|
397
|
+
return buf
|
|
398
|
+
}
|
package/src/bindings.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @voyant-travel/workflows/bindings
|
|
2
|
+
//
|
|
3
|
+
// Runtime binding shim. Edge runtime passes through to native CF bindings;
|
|
4
|
+
// container runtime makes authenticated HTTPS calls to CF's per-binding APIs.
|
|
5
|
+
//
|
|
6
|
+
// Contract defined in docs/sdk-surface.md §9 and docs/design.md §5.2.
|
|
7
|
+
|
|
8
|
+
// Types intentionally mirror Cloudflare's native binding shapes so the
|
|
9
|
+
// same code runs on edge and container runtimes. Runtime behavior is
|
|
10
|
+
// provided by the platform (native bindings on Workers; an HTTPS shim
|
|
11
|
+
// on the container runtime) — this package is the shared type surface.
|
|
12
|
+
|
|
13
|
+
export interface Env {
|
|
14
|
+
[key: string]: Binding
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Binding = D1Database | R2Bucket | KVNamespace | Queue<unknown> | string // secrets
|
|
18
|
+
|
|
19
|
+
export interface D1Database {
|
|
20
|
+
prepare(sql: string): D1PreparedStatement
|
|
21
|
+
batch(statements: D1PreparedStatement[]): Promise<D1Result[]>
|
|
22
|
+
exec(sql: string): Promise<D1ExecResult>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface D1PreparedStatement {
|
|
26
|
+
bind(...values: unknown[]): D1PreparedStatement
|
|
27
|
+
first<T = unknown>(column?: string): Promise<T | null>
|
|
28
|
+
run(): Promise<D1Result>
|
|
29
|
+
all<T = unknown>(): Promise<{ results: T[]; meta: D1Meta }>
|
|
30
|
+
/** Per-step-invocation read cache. */
|
|
31
|
+
memoize(): D1PreparedStatement
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface D1Result {
|
|
35
|
+
success: boolean
|
|
36
|
+
meta: D1Meta
|
|
37
|
+
results?: unknown[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface D1ExecResult {
|
|
41
|
+
count: number
|
|
42
|
+
duration: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface D1Meta {
|
|
46
|
+
duration: number
|
|
47
|
+
rows_read: number
|
|
48
|
+
rows_written: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface R2Bucket {
|
|
52
|
+
get(key: string, opts?: R2GetOptions): Promise<R2Object | null>
|
|
53
|
+
put(key: string, value: R2PutBody, opts?: R2PutOptions): Promise<R2Object>
|
|
54
|
+
delete(keys: string | string[]): Promise<void>
|
|
55
|
+
list(opts?: R2ListOptions): Promise<R2Objects>
|
|
56
|
+
head(key: string): Promise<R2Object | null>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface R2GetOptions {
|
|
60
|
+
range?: { offset?: number; length?: number }
|
|
61
|
+
onlyIf?: { etagMatches?: string }
|
|
62
|
+
}
|
|
63
|
+
export interface R2PutOptions {
|
|
64
|
+
httpMetadata?: Record<string, string>
|
|
65
|
+
customMetadata?: Record<string, string>
|
|
66
|
+
}
|
|
67
|
+
export type R2PutBody = ReadableStream | ArrayBuffer | string
|
|
68
|
+
export interface R2Object {
|
|
69
|
+
key: string
|
|
70
|
+
size: number
|
|
71
|
+
etag: string
|
|
72
|
+
httpMetadata?: Record<string, string>
|
|
73
|
+
customMetadata?: Record<string, string>
|
|
74
|
+
body?: ReadableStream
|
|
75
|
+
arrayBuffer(): Promise<ArrayBuffer>
|
|
76
|
+
text(): Promise<string>
|
|
77
|
+
json<T = unknown>(): Promise<T>
|
|
78
|
+
}
|
|
79
|
+
export interface R2ListOptions {
|
|
80
|
+
prefix?: string
|
|
81
|
+
cursor?: string
|
|
82
|
+
limit?: number
|
|
83
|
+
}
|
|
84
|
+
export interface R2Objects {
|
|
85
|
+
objects: R2Object[]
|
|
86
|
+
truncated: boolean
|
|
87
|
+
cursor?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface KVNamespace {
|
|
91
|
+
get<T = string>(key: string, opts?: KVGetOptions<T>): Promise<T | null>
|
|
92
|
+
put(key: string, value: string | ArrayBuffer, opts?: KVPutOptions): Promise<void>
|
|
93
|
+
delete(key: string): Promise<void>
|
|
94
|
+
list(opts?: KVListOptions): Promise<KVList>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface KVGetOptions<T> {
|
|
98
|
+
type?: T extends string ? "text" : "json" | "arrayBuffer" | "stream"
|
|
99
|
+
}
|
|
100
|
+
export interface KVPutOptions {
|
|
101
|
+
expiration?: number
|
|
102
|
+
expirationTtl?: number
|
|
103
|
+
metadata?: Record<string, unknown>
|
|
104
|
+
}
|
|
105
|
+
export interface KVListOptions {
|
|
106
|
+
prefix?: string
|
|
107
|
+
cursor?: string
|
|
108
|
+
limit?: number
|
|
109
|
+
}
|
|
110
|
+
export interface KVList {
|
|
111
|
+
keys: { name: string; expiration?: number; metadata?: unknown }[]
|
|
112
|
+
list_complete: boolean
|
|
113
|
+
cursor?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface Queue<T> {
|
|
117
|
+
send(message: T, opts?: { delaySeconds?: number }): Promise<void>
|
|
118
|
+
sendBatch(messages: readonly T[]): Promise<void>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The environment object tenant code reads bindings from.
|
|
123
|
+
*
|
|
124
|
+
* On the edge runtime, this is a pass-through to the tenant worker's
|
|
125
|
+
* `env` parameter (CF Workers). On the container runtime, the
|
|
126
|
+
* platform injects HTTP-based clients that mimic the shapes above.
|
|
127
|
+
*/
|
|
128
|
+
export const env: Env = new Proxy({} as Env, {
|
|
129
|
+
get(_, key: string): Binding {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`@voyant-travel/workflows/bindings: env.${key} was accessed outside a workflow / step body. ` +
|
|
132
|
+
`Bindings are injected by the runtime — see docs/sdk-surface.md §9.`,
|
|
133
|
+
)
|
|
134
|
+
},
|
|
135
|
+
})
|