@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 +21 -0
- package/README.md +198 -0
- package/dist/index.cjs +399 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +336 -0
- package/dist/index.d.ts +336 -0
- package/dist/index.js +382 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
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
|