@wayai/executor-sdk 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WayAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # @wayai/executor-sdk
2
+
3
+ Receive and verify [Rekor](https://github.com/wayai-pro/rekor)'s HMAC-signed, idempotency-keyed
4
+ webhooks and proxied calls — the **executor** side of the Rekor Executor Contract. Verify the
5
+ signature, enforce timestamp skew, dedupe retries, and return the right HTTP status, in a few lines.
6
+
7
+ Zero dependencies, dual ESM/CJS, runs anywhere with Web Crypto (Node ≥20, Cloudflare Workers, Deno,
8
+ Bun). The executor is v0's capability; future capabilities will be added as exports here or under
9
+ `@wayai/executor-sdk/<capability>` subpaths.
10
+
11
+ ```bash
12
+ npm i @wayai/executor-sdk
13
+ ```
14
+
15
+ ## The wire
16
+
17
+ Rekor signs every request it sends over **Signing v1**. The signature is HMAC-SHA256 over a canonical
18
+ string, sent as `X-Data-Signature: v1,<hex>` (a space-delimited list so a key rotation can carry old +
19
+ new). The canonical is:
20
+
21
+ ```
22
+ ["v1", id, String(unixTimestamp), method, path, body].join("\n")
23
+ ```
24
+
25
+ | Header | Meaning |
26
+ |--------|---------|
27
+ | `X-Data-Signature` | `v1,<hex>` — HMAC over the canonical |
28
+ | `X-Data-Timestamp` | unix seconds; generated per attempt (you reject stale ones) |
29
+ | `X-Data-Id` | the signed message id (the value bound into the canonical) |
30
+ | `X-Data-Idempotency-Key` | logical dedupe key on side-effecting calls (= `X-Data-Id` on writes) |
31
+ | `X-Data-Delivery-Id` | per-delivery id on webhooks (for tracing) |
32
+
33
+ `path` is the receiver URL's `pathname + search`. `body` is the **raw** request bytes. Verify
34
+ **before** parsing the body. Skew enforcement is your job — Rekor only emits.
35
+
36
+ ## Quickstart — Fetch (Hono / Next.js / Workers / Deno)
37
+
38
+ ```ts
39
+ import { createExecutor, toFetchHandler, ExecutorError } from '@wayai/executor-sdk';
40
+
41
+ const handler = toFetchHandler(createExecutor({
42
+ secret: process.env.REKOR_SIGNING_SECRET!,
43
+ async handler(ctx) {
44
+ // ctx: { id, idempotencyKey?, deliveryId?, timestamp, body } — body is the raw string
45
+ const event = JSON.parse(ctx.body);
46
+ // ... do the work, scoped to the signed request ...
47
+ return { status: 200, body: JSON.stringify({ ok: true }) };
48
+ },
49
+ }));
50
+
51
+ // Hono: app.post('/rekor', (c) => handler(c.req.raw));
52
+ // Next.js: export const POST = (req: Request) => handler(req);
53
+ // CF Workers: export default { fetch: handler };
54
+ ```
55
+
56
+ Throw an `ExecutorError` to control the response — `retriable` maps to the HTTP status family:
57
+
58
+ ```ts
59
+ throw new ExecutorError({ code: 'UPSTREAM_DOWN', message: 'try later', retriable: true }); // → 503 (Rekor retries)
60
+ throw new ExecutorError({ code: 'BAD_INPUT', message: 'no', retriable: false, status: 422 }); // → 422 (Rekor drops)
61
+ ```
62
+
63
+ ## Quickstart — Node / Express
64
+
65
+ ```ts
66
+ import express from 'express';
67
+ import { createExecutor, toNodeHandler } from '@wayai/executor-sdk';
68
+
69
+ const app = express();
70
+ const rekor = toNodeHandler(createExecutor({ secret: process.env.REKOR_SIGNING_SECRET!, handler: async (ctx) => { /* ... */ } }));
71
+
72
+ // ⚠️ Mount BEFORE any body parser (express.json(), body-parser…). The adapter reads the raw stream;
73
+ // if a parser consumed it first, the raw bytes are gone and verification fails.
74
+ app.post('/rekor', rekor);
75
+ ```
76
+
77
+ ## Verifying responses → retry behavior
78
+
79
+ Return the right **status** — that's the contract Rekor's retry logic reads:
80
+
81
+ | Your response | Rekor |
82
+ |---------------|-------|
83
+ | `2xx` | ack, done |
84
+ | `4xx` (except 429) | permanent failure — **not** retried |
85
+ | `429` / `5xx` | transient — retried with backoff |
86
+
87
+ > Note: Rekor's delivery currently retries **any** non-2xx response until the attempt budget is
88
+ > exhausted, then dead-letters it. Returning a `4xx` is the right signal for "permanent, don't retry"
89
+ > and is the convention to code against — Rekor's backend is converging on honoring it.
90
+
91
+ `createExecutor` does this for you: a valid request runs your handler; a thrown `ExecutorError`
92
+ becomes `{ status, code, message, retriable }` with the matching status; an unexpected throw becomes a
93
+ retriable `500`-family response (and calls `onError`, which never receives the secret).
94
+
95
+ ## Idempotency
96
+
97
+ Deliveries are deduped on `X-Data-Idempotency-Key` (webhooks fall back to the stable `X-Data-Id`).
98
+ The first request runs; a duplicate **replays the cached response** without re-running your handler. A
99
+ *retriable* failure clears the key so the retry actually re-runs; a *permanent* failure is cached.
100
+
101
+ The default `MemoryStore` is **per-process** and bounded by the deliveries within one TTL window
102
+ (default 24h) — a periodic sweep drops expired entries, so it doesn't grow forever, but a very
103
+ high-volume receiver should size the TTL or use a shared store. Running more than one instance? Supply
104
+ a shared store (Redis / a Durable Object / a DB) implementing `IdempotencyStore`:
105
+
106
+ ```ts
107
+ import { createExecutor, type IdempotencyStore } from '@wayai/executor-sdk';
108
+ const store: IdempotencyStore = { async claim(k, ttl) { /* SET NX */ }, async complete(k, r, ttl) {}, async fail(k) {} };
109
+ createExecutor({ secret, store, handler });
110
+ ```
111
+
112
+ (Read-only proxied requests carry a fresh id, so wrapping them is a harmless no-op — a network store
113
+ turns every read into a write, so you needn't route reads through the executor.)
114
+
115
+ ## Signing requests to Rekor (async write-back)
116
+
117
+ For a slow/async action, an executor does the work and POSTs the result back to a Rekor **inbound webhook**. That
118
+ inbound request must be signed the same way Rekor signs outbound — `signRequest` produces the headers
119
+ from the inbound webhook's shared secret (the inverse of the verify side; same canonical, conformance-pinned):
120
+
121
+ ```ts
122
+ import { signRequest } from '@wayai/executor-sdk';
123
+
124
+ const url = 'https://api.rekor.pro/inbound/<org>/<db>/<inbound_webhook_id>/ingest';
125
+ const body = JSON.stringify({ collection: 'results', data: { ok: true } });
126
+ const headers = await signRequest({ secret: process.env.REKOR_HOOK_SECRET!, method: 'POST', url, body });
127
+
128
+ await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
129
+ ```
130
+
131
+ `signRequest` returns `X-Data-Signature` / `X-Data-Timestamp` / `X-Data-Id` (id defaults to a fresh
132
+ UUID, timestamp to now). Sign the **exact** body bytes you send, and use the full URL — its
133
+ `pathname + search` is bound into the signature.
134
+
135
+ ## Pulling a credential (mTLS certs, binary secrets)
136
+
137
+ When a source declares `executor_secrets`, each proxied dispatch carries a **short-lived, single-use
138
+ grant**. Pull a declared credential by name with the inbound request — for a binary or per-tenant secret
139
+ too large to inject inline (e.g. an mTLS client cert the executor presents to its upstream):
140
+
141
+ ```ts
142
+ import { fetchExecutorCredential } from '@wayai/executor-sdk';
143
+
144
+ // `req` is the inbound dispatch (a Fetch Request, a NormalizedRequest, or its headers).
145
+ const cert = await fetchExecutorCredential(req, 'partner-cert'); // { name, contentType, value }
146
+ const pfx = Buffer.from(cert.value, 'base64'); // value is verbatim; decode per contentType
147
+ ```
148
+
149
+ `value` is delivered verbatim (Rekor stores binary credentials base64-encoded — decode per `contentType`).
150
+ The grant is **single-use per name**, so pull each credential **once per invocation and reuse** it; a
151
+ re-pull fails. Throws `ExecutorError` if the request carries no grant, the name isn't declared, or the
152
+ pull is denied (`403`, never retried) / unavailable (transient `5xx`/network → retried once).
153
+
154
+ **Call only on a verified request** (e.g. inside your `createExecutor` handler). The grant headers aren't
155
+ part of the request signature, so if your executor sits behind a TLS-terminating proxy, pin Rekor's origin
156
+ to reject a rewritten callback URL: `fetchExecutorCredential(req, name, { allowedOrigins: ['https://api.rekor.pro'] })`.
157
+
158
+ ## Path matching (reverse proxies)
159
+
160
+ The signature binds `path` = `new URL(receiverUrl).pathname + search` exactly as Rekor parsed it, with
161
+ **no normalization**. A proxy that adds/strips a trailing slash, reorders or re-encodes query params,
162
+ or normalizes percent-encoding will change the path your runtime sees and break verification. If you
163
+ sit behind such a proxy, pass `pathOverride(req)` returning the path Rekor *signed*, byte-for-byte:
164
+
165
+ ```ts
166
+ createExecutor({ secret, handler, pathOverride: () => '/rekor' });
167
+ ```
168
+
169
+ ## Security notes
170
+
171
+ - **Verify before you trust** — never act on an unverified body.
172
+ - **Raw body** — verify the exact bytes; don't `JSON.parse` (or let a framework parse) before verifying.
173
+ - **Skew window** — default 300s, both directions; `toleranceSec` is clamped to ≥1 (you can't disable it).
174
+ - **Secrets** — the signing secret never appears in `ctx`, the result, the error envelope, `onError`, or
175
+ any log this library emits (it emits none).
176
+ - **Injected secrets** — operator-configured per-request secrets arrive as ordinary headers/body fields
177
+ on the verified request; read them like any other field. Use them in memory, never persist.
178
+ - **Signature list** — bounded to a few candidates, so a hostile header can't force unbounded HMACs.
179
+ - **Body size** — the adapters read the full raw body into memory before verifying (verification needs
180
+ the exact bytes). Cap request size at your framework/reverse proxy (e.g. a body-size limit on the
181
+ Rekor route) so an oversized request can't exhaust memory ahead of verification.
182
+
183
+ ## Dev bypass
184
+
185
+ `createExecutor({ secret, handler, devBypass: true })` skips signature/skew verification (dedup still
186
+ applies if the headers are present). **Local development only** — never enable in production.
187
+
188
+ ## API
189
+
190
+ `createExecutor(options)` · `toFetchHandler(executor)` · `toNodeHandler(executor)` ·
191
+ `verify(input)` / `verifySignatureHeader(secret, parts, header)` · `signRequest(input)` ·
192
+ `fetchExecutorCredential(req, name, opts?)` ·
193
+ `MemoryStore` / `IdempotencyStore` · `ExecutorError` / `toHttpStatus` ·
194
+ `buildCanonical` / `unixSeconds` / `SIGNING_SCHEME` / `DATA_HEADERS`.
195
+
196
+ ## License
197
+
198
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+
3
+ // src/core/hmac.ts
4
+ async function signPayload(secret, payload) {
5
+ const key = await crypto.subtle.importKey(
6
+ "raw",
7
+ new TextEncoder().encode(secret),
8
+ { name: "HMAC", hash: "SHA-256" },
9
+ false,
10
+ ["sign"]
11
+ );
12
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
13
+ return arrayBufferToHex(signature);
14
+ }
15
+ function timingSafeEqual(a, b) {
16
+ if (a.length !== b.length) return false;
17
+ let result = 0;
18
+ for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
19
+ return result === 0;
20
+ }
21
+ function arrayBufferToHex(buffer) {
22
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
23
+ }
24
+
25
+ // src/core/headers.ts
26
+ function headerGetter(headers) {
27
+ if (headers instanceof Headers) return (name) => headers.get(name) ?? void 0;
28
+ const lower = {};
29
+ for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
30
+ return (name) => {
31
+ const v = lower[name];
32
+ if (Array.isArray(v)) return v.length === 1 ? v[0] : void 0;
33
+ return v;
34
+ };
35
+ }
36
+
37
+ // src/core/canonical.ts
38
+ var SIGNING_SCHEME = "v1";
39
+ function buildCanonical(parts) {
40
+ return [SIGNING_SCHEME, parts.id, String(parts.timestamp), parts.method, parts.path, parts.body].join("\n");
41
+ }
42
+ function unixSeconds(nowMs) {
43
+ return Math.floor((nowMs ?? Date.now()) / 1e3);
44
+ }
45
+
46
+ // src/core/verify.ts
47
+ var DATA_HEADERS = {
48
+ signature: "x-data-signature",
49
+ timestamp: "x-data-timestamp",
50
+ id: "x-data-id",
51
+ idempotencyKey: "x-data-idempotency-key",
52
+ deliveryId: "x-data-delivery-id",
53
+ // Executor credential pull: advertised on a proxied dispatch to a source declaring executor_secrets.
54
+ credentialUrl: "x-data-credential-url",
55
+ credentialToken: "x-data-credential-token",
56
+ credentialNames: "x-data-credential-names"
57
+ };
58
+ var MAX_SIG_ENTRIES = 4;
59
+ async function verifySignatureHeader(secret, parts, header) {
60
+ const canonical = buildCanonical(parts);
61
+ let expected;
62
+ let checked = 0;
63
+ let matched = false;
64
+ for (const entry of header.split(" ")) {
65
+ const comma = entry.indexOf(",");
66
+ if (comma <= 0) continue;
67
+ if (entry.slice(0, comma) !== SIGNING_SCHEME) continue;
68
+ const hex = entry.slice(comma + 1);
69
+ if (hex.length === 0) continue;
70
+ if (++checked > MAX_SIG_ENTRIES) break;
71
+ if (expected === void 0) expected = await signPayload(secret, canonical);
72
+ if (timingSafeEqual(expected, hex)) matched = true;
73
+ }
74
+ return matched;
75
+ }
76
+ async function verify(input) {
77
+ const get = headerGetter(input.headers);
78
+ const signature = get(DATA_HEADERS.signature);
79
+ if (!signature) return { ok: false, reason: "missing_signature" };
80
+ const tsRaw = get(DATA_HEADERS.timestamp);
81
+ const id = get(DATA_HEADERS.id);
82
+ if (!tsRaw || !id) return { ok: false, reason: "malformed" };
83
+ if (!/^\d+$/.test(tsRaw)) return { ok: false, reason: "malformed" };
84
+ const timestamp = Number(tsRaw);
85
+ if (!Number.isFinite(timestamp)) return { ok: false, reason: "malformed" };
86
+ const t = input.toleranceSec;
87
+ const tolerance = Number.isFinite(t) ? Math.max(1, Math.floor(t)) : 300;
88
+ const now = unixSeconds(input.now);
89
+ if (Math.abs(now - timestamp) > tolerance) return { ok: false, reason: "stale_timestamp" };
90
+ const parts = { id, timestamp, method: input.method, path: input.path, body: input.rawBody };
91
+ const valid = await verifySignatureHeader(input.secret, parts, signature);
92
+ if (!valid) return { ok: false, reason: "invalid_signature" };
93
+ return {
94
+ ok: true,
95
+ id,
96
+ timestamp,
97
+ idempotencyKey: get(DATA_HEADERS.idempotencyKey),
98
+ deliveryId: get(DATA_HEADERS.deliveryId),
99
+ body: input.rawBody
100
+ };
101
+ }
102
+
103
+ // src/store/memory.ts
104
+ var DEFAULT_SWEEP_EVERY = 1e3;
105
+ var MemoryStore = class {
106
+ map = /* @__PURE__ */ new Map();
107
+ nowMs;
108
+ sweepEvery;
109
+ writes = 0;
110
+ constructor(opts) {
111
+ this.nowMs = opts?.now ?? (() => Date.now());
112
+ this.sweepEvery = opts?.sweepEvery ?? DEFAULT_SWEEP_EVERY;
113
+ }
114
+ /** Number of live entries currently held (after lazily ignoring nothing — counts raw map size). */
115
+ get size() {
116
+ return this.map.size;
117
+ }
118
+ live(key) {
119
+ const e = this.map.get(key);
120
+ if (!e) return void 0;
121
+ if (e.expiresAt <= this.nowMs()) {
122
+ this.map.delete(key);
123
+ return void 0;
124
+ }
125
+ return e;
126
+ }
127
+ /** Amortized O(1)/write: every SWEEP_EVERY writes, drop all expired entries so the map can't grow forever. */
128
+ maybeSweep() {
129
+ if (++this.writes < this.sweepEvery) return;
130
+ this.writes = 0;
131
+ const now = this.nowMs();
132
+ for (const [k, e] of this.map) if (e.expiresAt <= now) this.map.delete(k);
133
+ }
134
+ async claim(key, ttlSec) {
135
+ const e = this.live(key);
136
+ if (!e) {
137
+ this.map.set(key, { state: "pending", expiresAt: this.nowMs() + ttlSec * 1e3 });
138
+ this.maybeSweep();
139
+ return { state: "fresh" };
140
+ }
141
+ if (e.state === "done" && e.result) return { state: "done", result: e.result };
142
+ return { state: "in_progress" };
143
+ }
144
+ async complete(key, result, ttlSec) {
145
+ this.map.set(key, { state: "done", result, expiresAt: this.nowMs() + ttlSec * 1e3 });
146
+ this.maybeSweep();
147
+ }
148
+ async fail(key) {
149
+ this.map.delete(key);
150
+ }
151
+ };
152
+
153
+ // src/errors/envelope.ts
154
+ var ExecutorError = class extends Error {
155
+ code;
156
+ retriable;
157
+ httpStatus;
158
+ constructor(args) {
159
+ super(args.message);
160
+ this.name = "ExecutorError";
161
+ this.code = args.code;
162
+ this.retriable = args.retriable;
163
+ this.httpStatus = args.status;
164
+ }
165
+ toEnvelope() {
166
+ return { status: toHttpStatus(this), code: this.code, message: this.message, retriable: this.retriable };
167
+ }
168
+ };
169
+ function toHttpStatus(e) {
170
+ const s = e.httpStatus;
171
+ if (e.retriable) {
172
+ return s !== void 0 && (s === 429 || s >= 500 && s <= 599) ? s : 503;
173
+ }
174
+ return s !== void 0 && s >= 400 && s <= 499 ? s : 400;
175
+ }
176
+ function jsonResponseBody(env) {
177
+ return JSON.stringify(env);
178
+ }
179
+
180
+ // src/executor.ts
181
+ var JSON_CT = "application/json";
182
+ function errorResponse(env) {
183
+ return { status: env.status, body: jsonResponseBody(env), contentType: JSON_CT };
184
+ }
185
+ function reasonResponse(reason) {
186
+ const status = reason === "malformed" ? 400 : reason === "stale_timestamp" ? 403 : 401;
187
+ return errorResponse({ status, code: reason.toUpperCase(), message: `signature ${reason}`, retriable: false });
188
+ }
189
+ function bypassContext(req) {
190
+ const get = headerGetter(req.headers);
191
+ const id = get(DATA_HEADERS.id);
192
+ const tsRaw = get(DATA_HEADERS.timestamp);
193
+ return {
194
+ id: id ?? "",
195
+ idempotencyKey: get(DATA_HEADERS.idempotencyKey),
196
+ deliveryId: get(DATA_HEADERS.deliveryId),
197
+ timestamp: tsRaw && /^\d+$/.test(tsRaw) ? Number(tsRaw) : 0,
198
+ body: req.rawBody
199
+ };
200
+ }
201
+ function createExecutor(opts) {
202
+ const store = opts.store ?? new MemoryStore();
203
+ const ttl = opts.idempotencyTtlSec ?? 86400;
204
+ async function run(ctx) {
205
+ const key = ctx.idempotencyKey || ctx.id;
206
+ if (!key) return await invoke(ctx);
207
+ const claim = await store.claim(key, ttl);
208
+ if (claim.state === "done") {
209
+ return { status: claim.result.status, body: claim.result.body, contentType: claim.result.contentType ?? JSON_CT };
210
+ }
211
+ if (claim.state === "in_progress") {
212
+ return errorResponse({ status: 503, code: "IN_PROGRESS", message: "duplicate delivery in progress", retriable: true });
213
+ }
214
+ try {
215
+ const res = await invoke(ctx);
216
+ await store.complete(key, { status: res.status, body: res.body, contentType: res.contentType }, ttl);
217
+ return res;
218
+ } catch (err) {
219
+ const env = toEnvelope(err);
220
+ const res = errorResponse(env);
221
+ if (env.retriable) {
222
+ await store.fail(key);
223
+ } else {
224
+ await store.complete(key, { status: res.status, body: res.body, contentType: res.contentType }, ttl);
225
+ }
226
+ if (!(err instanceof ExecutorError)) opts.onError?.(err, ctx);
227
+ return res;
228
+ }
229
+ }
230
+ async function invoke(ctx) {
231
+ const result = await opts.handler(ctx);
232
+ if (!result) return { status: 204, body: "", contentType: JSON_CT };
233
+ return { status: result.status, body: result.body, contentType: result.contentType ?? JSON_CT };
234
+ }
235
+ return {
236
+ async handle(req) {
237
+ const path = opts.pathOverride ? opts.pathOverride(req) : req.path;
238
+ if (opts.devBypass) {
239
+ const ctx = bypassContext(req);
240
+ return ctx ? await run(ctx) : reasonResponse("malformed");
241
+ }
242
+ const result = await verify({
243
+ secret: opts.secret,
244
+ headers: req.headers,
245
+ method: req.method,
246
+ path,
247
+ rawBody: req.rawBody,
248
+ toleranceSec: opts.toleranceSec
249
+ });
250
+ if (!result.ok) return reasonResponse(result.reason);
251
+ return await run({
252
+ id: result.id,
253
+ idempotencyKey: result.idempotencyKey,
254
+ deliveryId: result.deliveryId,
255
+ timestamp: result.timestamp,
256
+ body: result.body
257
+ });
258
+ }
259
+ };
260
+ }
261
+ function toEnvelope(err) {
262
+ if (err instanceof ExecutorError) return err.toEnvelope();
263
+ return { status: toHttpStatus({ retriable: true }), code: "INTERNAL", message: "handler error", retriable: true };
264
+ }
265
+
266
+ // src/adapters/fetch.ts
267
+ function toFetchHandler(executor) {
268
+ return async (req) => {
269
+ const rawBody = await req.text();
270
+ const url = new URL(req.url);
271
+ const res = await executor.handle({
272
+ method: req.method,
273
+ path: url.pathname + url.search,
274
+ headers: req.headers,
275
+ rawBody
276
+ });
277
+ return new Response(res.body || null, { status: res.status, headers: { "content-type": res.contentType } });
278
+ };
279
+ }
280
+
281
+ // src/adapters/node.ts
282
+ function toNodeHandler(executor) {
283
+ return async (req, res) => {
284
+ const chunks = [];
285
+ for await (const chunk of req) {
286
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
287
+ }
288
+ const rawBody = Buffer.concat(chunks).toString("utf8");
289
+ const out = await executor.handle({
290
+ method: req.method ?? "GET",
291
+ path: req.url ?? "/",
292
+ headers: req.headers,
293
+ rawBody
294
+ });
295
+ res.writeHead(out.status, { "content-type": out.contentType });
296
+ res.end(out.body);
297
+ };
298
+ }
299
+
300
+ // src/core/sign.ts
301
+ async function signRequest(input) {
302
+ const id = input.id ?? crypto.randomUUID();
303
+ const timestamp = input.timestamp ?? unixSeconds();
304
+ const body = input.body ?? "";
305
+ const { pathname, search } = new URL(input.url);
306
+ const hex = await signPayload(input.secret, buildCanonical({ id, timestamp, method: input.method, path: pathname + search, body }));
307
+ return {
308
+ "X-Data-Signature": `${SIGNING_SCHEME},${hex}`,
309
+ "X-Data-Timestamp": String(timestamp),
310
+ "X-Data-Id": id
311
+ };
312
+ }
313
+
314
+ // src/core/credential.ts
315
+ function resolveHeaders(source) {
316
+ if (source instanceof Headers) return source;
317
+ const nested = source.headers;
318
+ if (nested instanceof Headers) return nested;
319
+ if (nested && typeof nested === "object") return nested;
320
+ return source;
321
+ }
322
+ async function fetchExecutorCredential(source, name, opts = {}) {
323
+ const get = headerGetter(resolveHeaders(source));
324
+ const url = get(DATA_HEADERS.credentialUrl);
325
+ const token = get(DATA_HEADERS.credentialToken);
326
+ const namesHeader = get(DATA_HEADERS.credentialNames);
327
+ if (!url || !token || namesHeader === void 0) {
328
+ throw new ExecutorError({ code: "no_credential_grant", message: "Request carries no executor credential grant", retriable: false });
329
+ }
330
+ if (opts.allowedOrigins && opts.allowedOrigins.length > 0) {
331
+ let origin;
332
+ try {
333
+ origin = new URL(url).origin;
334
+ } catch {
335
+ throw new ExecutorError({ code: "credential_bad_url", message: "Credential callback URL is not a valid URL", retriable: false });
336
+ }
337
+ const allowed = opts.allowedOrigins.map((o) => {
338
+ try {
339
+ return new URL(o).origin;
340
+ } catch {
341
+ return o;
342
+ }
343
+ });
344
+ if (!allowed.includes(origin)) {
345
+ throw new ExecutorError({ code: "credential_untrusted_url", message: `Credential callback URL origin '${origin}' is not in allowedOrigins`, retriable: false });
346
+ }
347
+ }
348
+ const names = namesHeader.split(",").map((n) => n.trim()).filter(Boolean);
349
+ if (!names.includes(name)) {
350
+ throw new ExecutorError({ code: "undeclared_credential", message: `Credential '${name}' is not in the grant (declared: ${names.join(", ") || "none"})`, retriable: false });
351
+ }
352
+ const doFetch = opts.fetchImpl ?? fetch;
353
+ const body = JSON.stringify({ token, name });
354
+ for (let attempt = 0; attempt < 2; attempt++) {
355
+ let res;
356
+ try {
357
+ res = await doFetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, redirect: "manual" });
358
+ } catch {
359
+ if (attempt === 0) continue;
360
+ throw new ExecutorError({ code: "credential_unreachable", message: "Credential pull is unreachable", retriable: true });
361
+ }
362
+ if (res.status === 403) {
363
+ throw new ExecutorError({ code: "credential_denied", message: `Credential '${name}' pull was denied (grant invalid, expired, or already used)`, retriable: false, status: 403 });
364
+ }
365
+ if (res.status >= 500) {
366
+ if (attempt === 0) continue;
367
+ throw new ExecutorError({ code: "credential_unavailable", message: `Credential pull failed (status ${res.status})`, retriable: true, status: res.status });
368
+ }
369
+ if (!res.ok) {
370
+ throw new ExecutorError({ code: "credential_fetch_failed", message: `Credential pull failed (status ${res.status})`, retriable: false, status: res.status });
371
+ }
372
+ const parsed = await res.json().catch(() => null);
373
+ const data = parsed?.data;
374
+ if (!data || typeof data.value !== "string") {
375
+ throw new ExecutorError({ code: "credential_malformed", message: "Credential pull returned an unexpected body", retriable: false });
376
+ }
377
+ return { name: data.name ?? name, contentType: data.content_type ?? "application/octet-stream", value: data.value };
378
+ }
379
+ throw new ExecutorError({ code: "credential_unavailable", message: "Credential pull failed", retriable: true });
380
+ }
381
+
382
+ exports.DATA_HEADERS = DATA_HEADERS;
383
+ exports.ExecutorError = ExecutorError;
384
+ exports.MAX_SIG_ENTRIES = MAX_SIG_ENTRIES;
385
+ exports.MemoryStore = MemoryStore;
386
+ exports.SIGNING_SCHEME = SIGNING_SCHEME;
387
+ exports.buildCanonical = buildCanonical;
388
+ exports.createExecutor = createExecutor;
389
+ exports.fetchExecutorCredential = fetchExecutorCredential;
390
+ exports.jsonResponseBody = jsonResponseBody;
391
+ exports.signRequest = signRequest;
392
+ exports.toFetchHandler = toFetchHandler;
393
+ exports.toHttpStatus = toHttpStatus;
394
+ exports.toNodeHandler = toNodeHandler;
395
+ exports.unixSeconds = unixSeconds;
396
+ exports.verify = verify;
397
+ exports.verifySignatureHeader = verifySignatureHeader;
398
+ //# sourceMappingURL=index.cjs.map
399
+ //# sourceMappingURL=index.cjs.map