@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.
@@ -0,0 +1,336 @@
1
+ /** The cached outcome of a completed request, replayed verbatim on a duplicate delivery. */
2
+ interface StoredResult {
3
+ status: number;
4
+ body: string;
5
+ contentType?: string;
6
+ }
7
+ type ClaimResult = {
8
+ state: 'fresh';
9
+ } | {
10
+ state: 'in_progress';
11
+ } | {
12
+ state: 'done';
13
+ result: StoredResult;
14
+ };
15
+ /**
16
+ * Idempotency store: dedupe deliveries by key with first-request-wins semantics. `MemoryStore` is the
17
+ * default; supply your own (Redis / a Durable Object / a DB) when running more than one instance, since
18
+ * an in-memory store is per-process.
19
+ */
20
+ interface IdempotencyStore {
21
+ /** Atomically claim a key. `fresh` ⇒ caller proceeds; `in_progress`/`done` ⇒ don't re-run. */
22
+ claim(key: string, ttlSec: number): Promise<ClaimResult>;
23
+ /** Record the terminal result so future duplicates replay it. */
24
+ complete(key: string, result: StoredResult, ttlSec: number): Promise<void>;
25
+ /** Release a key after a retriable failure so the next attempt re-runs (don't cache failures). */
26
+ fail(key: string): Promise<void>;
27
+ }
28
+
29
+ /** Framework-neutral request the adapters hand to the executor. `rawBody` is the RAW bytes as a string. */
30
+ interface NormalizedRequest {
31
+ method: string;
32
+ path: string;
33
+ headers: Headers | Record<string, string | string[] | undefined>;
34
+ rawBody: string;
35
+ }
36
+ interface NormalizedResponse {
37
+ status: number;
38
+ body: string;
39
+ contentType: string;
40
+ }
41
+ /** Verified request context passed to your handler. Never contains the signing secret. */
42
+ interface ExecutorContext {
43
+ id: string;
44
+ idempotencyKey?: string;
45
+ deliveryId?: string;
46
+ timestamp: number;
47
+ body: string;
48
+ }
49
+ /** Your business logic. Return a `StoredResult` (cached for idempotent replays) or nothing (→ 204). */
50
+ type ExecutorHandler = (ctx: ExecutorContext) => Promise<StoredResult | void> | StoredResult | void;
51
+ interface CreateExecutorOptions {
52
+ /** HMAC signing secret shared with Rekor. */
53
+ secret: string;
54
+ handler: ExecutorHandler;
55
+ /** Idempotency store. Default: a per-process {@link MemoryStore}. */
56
+ store?: IdempotencyStore;
57
+ /** Clock-skew tolerance (seconds, both directions). Default 300; clamped to ≥1. */
58
+ toleranceSec?: number;
59
+ /** Skip signature/skew verification (LOCAL DEV ONLY). Dedup still applies if headers are present. */
60
+ devBypass?: boolean;
61
+ /** Idempotency record TTL (seconds). Default 86400 (24h). */
62
+ idempotencyTtlSec?: number;
63
+ /** Reports unexpected handler errors. MUST NOT log secrets/PII — it never receives the secret. */
64
+ onError?: (err: unknown, ctx?: ExecutorContext) => void;
65
+ /**
66
+ * Reproduce the path Rekor SIGNED when a reverse proxy rewrites it. Must return the exact
67
+ * `pathname+search` Rekor used (see README path-matching), not necessarily the incoming path.
68
+ */
69
+ pathOverride?: (req: NormalizedRequest) => string;
70
+ }
71
+ interface Executor {
72
+ handle(req: NormalizedRequest): Promise<NormalizedResponse>;
73
+ }
74
+ declare function createExecutor(opts: CreateExecutorOptions): Executor;
75
+
76
+ /**
77
+ * Wrap an {@link Executor} as a Fetch-API handler `(Request) => Promise<Response>` — works on Hono,
78
+ * Next.js route handlers, Cloudflare Workers, Deno, and Bun.
79
+ *
80
+ * Reads the RAW body via `req.text()` BEFORE any parsing (verifying a re-stringified parsed body would
81
+ * fail). The signed `path` is `new URL(req.url).pathname + search`; if a reverse proxy rewrites the
82
+ * path, pass `pathOverride` to `createExecutor`.
83
+ */
84
+ declare function toFetchHandler(executor: Executor): (req: Request) => Promise<Response>;
85
+
86
+ /**
87
+ * Minimal structural shapes of Node's `http.IncomingMessage` / `http.ServerResponse` (and Express's
88
+ * `req`/`res`). Declared structurally so the package imports no Node built-ins at runtime.
89
+ */
90
+ interface NodeReq extends AsyncIterable<unknown> {
91
+ method?: string;
92
+ url?: string;
93
+ headers: Record<string, string | string[] | undefined>;
94
+ }
95
+ interface NodeRes {
96
+ writeHead(status: number, headers?: Record<string, string>): unknown;
97
+ end(chunk?: string): unknown;
98
+ }
99
+ /**
100
+ * Wrap an {@link Executor} as a Node/Express handler `(req, res) => Promise<void>`.
101
+ *
102
+ * IMPORTANT: this reads the RAW request stream itself, so it MUST run BEFORE any body parser
103
+ * (`express.json()`, `body-parser`, …). If a parser has already consumed the stream, the raw bytes are
104
+ * gone and verification fails. Mount this on the Rekor route before such middleware.
105
+ *
106
+ * The signed `path` comes from `req.url` (already `pathname+search`); use `pathOverride` for proxies.
107
+ */
108
+ declare function toNodeHandler(executor: Executor): (req: NodeReq, res: NodeRes) => Promise<void>;
109
+
110
+ /**
111
+ * Rekor "Signing v1" canonical — the exact string Rekor HMAC-signs for every request it sends
112
+ * (trigger webhooks + signed proxied calls). Ported verbatim from the backend's
113
+ * `workers/shared/src/signing.ts`; kept byte-identical by the cross-package conformance vector.
114
+ *
115
+ * canonical = ["v1", id, String(unixTimestamp), method, path, body].join("\n")
116
+ *
117
+ * SECURITY INVARIANT: newline-joined; the only data-influenced field, `body`, is LAST, and every
118
+ * other field is provably newline-free (the "v1" literal, an integer, a uuid/hex id, an HTTP method
119
+ * token, and a URL pathname+search). The signature binds method + path so it can't be replayed as a
120
+ * different request.
121
+ */
122
+ declare const SIGNING_SCHEME = "v1";
123
+ interface SignedParts {
124
+ /** Stable message id — `X-Data-Id` (= delivery id on webhooks, idempotency key on proxied writes). */
125
+ id: string;
126
+ /** `X-Data-Timestamp`, unix seconds (integer). */
127
+ timestamp: number;
128
+ /** HTTP method (`POST` for trigger webhooks). */
129
+ method: string;
130
+ /** Request path: `new URL(receiverUrl).pathname + search`. */
131
+ path: string;
132
+ /** Exact raw request body string (`""` when none). */
133
+ body: string;
134
+ }
135
+ /** Build the canonical signing string. Component order and the `v1` prefix are part of the wire. */
136
+ declare function buildCanonical(parts: SignedParts): string;
137
+ /** Current unix time in whole seconds (Standard Webhooks convention). `nowMs` override for tests. */
138
+ declare function unixSeconds(nowMs?: number): number;
139
+
140
+ /**
141
+ * Verify a Rekor "Signing v1" request: HMAC over the canonical, plus timestamp-skew enforcement
142
+ * (the receiver's job — Rekor only emits). All inputs are the raw request bits the adapter extracts.
143
+ */
144
+
145
+ /** Lowercased header names Rekor emits. */
146
+ declare const DATA_HEADERS: {
147
+ readonly signature: "x-data-signature";
148
+ readonly timestamp: "x-data-timestamp";
149
+ readonly id: "x-data-id";
150
+ readonly idempotencyKey: "x-data-idempotency-key";
151
+ readonly deliveryId: "x-data-delivery-id";
152
+ readonly credentialUrl: "x-data-credential-url";
153
+ readonly credentialToken: "x-data-credential-token";
154
+ readonly credentialNames: "x-data-credential-names";
155
+ };
156
+ /**
157
+ * Max `scheme,hex` candidates we'll HMAC-check from one `X-Data-Signature` header. Rekor emits one
158
+ * (two during a future key rotation); the cap stops a hostile header with thousands of entries from
159
+ * forcing thousands of HMACs per request.
160
+ */
161
+ declare const MAX_SIG_ENTRIES = 4;
162
+ /**
163
+ * Verify a signature header value (`X-Data-Signature`) against the canonical of `parts`. The header
164
+ * is a space-delimited list of `scheme,hex` entries; returns true if any `v1` entry matches (constant-
165
+ * time compare). Malformed/empty/unknown-scheme entries are skipped, never throw. Candidates beyond
166
+ * `MAX_SIG_ENTRIES` are ignored. Does NOT check timestamp skew (see {@link verify}).
167
+ */
168
+ declare function verifySignatureHeader(secret: string, parts: SignedParts, header: string): Promise<boolean>;
169
+ type VerifyResult = {
170
+ ok: true;
171
+ id: string;
172
+ timestamp: number;
173
+ idempotencyKey?: string;
174
+ deliveryId?: string;
175
+ body: string;
176
+ } | {
177
+ ok: false;
178
+ reason: 'missing_signature' | 'invalid_signature' | 'stale_timestamp' | 'malformed';
179
+ };
180
+ interface VerifyInput {
181
+ /** The HMAC signing secret shared with Rekor (the trigger/source signing secret). */
182
+ secret: string;
183
+ /** Request headers — a Fetch `Headers` or a plain object (case-insensitive lookup either way). */
184
+ headers: Headers | Record<string, string | string[] | undefined>;
185
+ /** HTTP method of the incoming request. */
186
+ method: string;
187
+ /** Request path the signature was computed over: `pathname + search` (see README path-matching). */
188
+ path: string;
189
+ /** RAW request body bytes as a string — NEVER a re-stringified parsed object. */
190
+ rawBody: string;
191
+ /** Allowed clock skew in seconds (both directions). A finite value is clamped to ≥1 (never 0);
192
+ * unset / NaN / Infinity fall back to the 300s default (a bad value can't disable the window). */
193
+ toleranceSec?: number;
194
+ /** Override "now" in ms (tests). */
195
+ now?: number;
196
+ }
197
+ /**
198
+ * Verify an inbound Rekor request end to end: signature present + valid, and timestamp within the
199
+ * skew window. Returns a discriminated result; never throws on bad input.
200
+ */
201
+ declare function verify(input: VerifyInput): Promise<VerifyResult>;
202
+
203
+ /**
204
+ * Sign a request TO Rekor — the inverse of the executor's verify side. Use this when an executor POSTs
205
+ * a result back to a Rekor inbound webhook (the async write-back leg): Rekor's inbound webhook ingest verifies a Signing v1
206
+ * signature, and this produces it from the inbound webhook's shared secret.
207
+ *
208
+ * Returns the three headers to attach (`X-Data-Signature`, `X-Data-Timestamp`, `X-Data-Id`). The
209
+ * canonical is byte-identical to the verify side (the cross-package conformance vector guards drift).
210
+ */
211
+ interface SignRequestInput {
212
+ /** HMAC shared secret for the target Rekor inbound webhook. */
213
+ secret: string;
214
+ /** HTTP method of the request being signed (e.g. 'POST'). */
215
+ method: string;
216
+ /** Full target URL — its `pathname + search` is bound into the signature. */
217
+ url: string;
218
+ /** Raw request body string (default `''`). MUST be the exact bytes sent. */
219
+ body?: string;
220
+ /** Message id → `X-Data-Id`, bound into the signature. Default: a generated UUID. */
221
+ id?: string;
222
+ /** Unix seconds → `X-Data-Timestamp`. Default: now. */
223
+ timestamp?: number;
224
+ }
225
+ interface SignedHeaders {
226
+ 'X-Data-Signature': string;
227
+ 'X-Data-Timestamp': string;
228
+ 'X-Data-Id': string;
229
+ }
230
+ /** Sign a request to Rekor and return the headers to attach. */
231
+ declare function signRequest(input: SignRequestInput): Promise<SignedHeaders>;
232
+
233
+ /**
234
+ * Executor credential pull — the client side of Rekor's scoped, single-use binary-credential delivery.
235
+ *
236
+ * When a Rekor source declares `executor_secrets`, every proxied dispatch to the executor carries
237
+ * `X-Data-Credential-Url`/`-Token`/`-Names`. The executor calls `fetchExecutorCredential` with the
238
+ * INBOUND request to pull a declared credential by name (e.g. an mTLS client cert it must present to a
239
+ * downstream upstream). The grant is single-use per name and short-lived, so fetch each credential ONCE
240
+ * per invocation and reuse the value — a re-pull of the same name fails (the grant is already claimed).
241
+ *
242
+ * The returned `value` is delivered verbatim (Rekor stores binary credentials base64-encoded); decode it
243
+ * per `contentType` (e.g. base64-decode an `application/x-pkcs12` bundle). This helper never decodes.
244
+ *
245
+ * SECURITY: call this ONLY from a handler whose inbound request the SDK has already verified (e.g. inside
246
+ * `createExecutor`'s handler). The grant headers are NOT part of the request's HMAC signature, so anything
247
+ * that can rewrite headers in front of your executor (a TLS-terminating proxy/gateway) could swap the
248
+ * callback URL. Pass `allowedOrigins` (your Rekor origin, e.g. `['https://api.rekor.pro']`) to reject a
249
+ * rewritten URL — recommended whenever your executor sits behind such an intermediary.
250
+ */
251
+ /** A pulled credential. `value` is delivered verbatim (base64 for binary); decode per `contentType`. */
252
+ interface ExecutorCredential {
253
+ name: string;
254
+ contentType: string;
255
+ value: string;
256
+ }
257
+ interface FetchCredentialOptions {
258
+ /** Override the fetch implementation (defaults to the global `fetch`). */
259
+ fetchImpl?: typeof fetch;
260
+ /**
261
+ * If set (and non-empty), the callback URL's origin must be one of these — rejects a URL rewritten in
262
+ * transit (the grant headers are NOT covered by the request signature). Pass your Rekor origin(s),
263
+ * e.g. `['https://api.rekor.pro']`. Recommended when the executor sits behind a TLS-terminating proxy.
264
+ */
265
+ allowedOrigins?: string[];
266
+ }
267
+ /** Anything carrying the inbound request headers: a NormalizedRequest, a Fetch `Request`, or raw headers. */
268
+ type HeaderSource = Headers | Record<string, string | string[] | undefined> | {
269
+ headers: Headers | Record<string, string | string[] | undefined>;
270
+ };
271
+ /**
272
+ * Pull a declared credential by name from Rekor, using the grant advertised on the inbound dispatch.
273
+ * Throws `ExecutorError` if the request carries no grant, the name isn't in the grant, or the pull is
274
+ * denied/fails. A 403 (grant invalid/expired/already-used) is never retried; a transient 5xx/network
275
+ * error is retried once.
276
+ */
277
+ declare function fetchExecutorCredential(source: HeaderSource, name: string, opts?: FetchCredentialOptions): Promise<ExecutorCredential>;
278
+
279
+ declare class MemoryStore implements IdempotencyStore {
280
+ private readonly map;
281
+ private readonly nowMs;
282
+ private readonly sweepEvery;
283
+ private writes;
284
+ constructor(opts?: {
285
+ now?: () => number;
286
+ sweepEvery?: number;
287
+ });
288
+ /** Number of live entries currently held (after lazily ignoring nothing — counts raw map size). */
289
+ get size(): number;
290
+ private live;
291
+ /** Amortized O(1)/write: every SWEEP_EVERY writes, drop all expired entries so the map can't grow forever. */
292
+ private maybeSweep;
293
+ claim(key: string, ttlSec: number): Promise<ClaimResult>;
294
+ complete(key: string, result: StoredResult, ttlSec: number): Promise<void>;
295
+ fail(key: string): Promise<void>;
296
+ }
297
+
298
+ /**
299
+ * The normalized error envelope an executor returns. The HTTP STATUS is the real contract a sender's
300
+ * retry logic reads (2xx ack, 4xx permanent/no-retry, 5xx & 429 retry); the JSON body is for humans.
301
+ */
302
+ interface ErrorEnvelope {
303
+ status: number;
304
+ code: string;
305
+ message: string;
306
+ retriable: boolean;
307
+ }
308
+ /**
309
+ * Throw this from a handler to fail with a specific code/retriability. `retriable` decides the status
310
+ * family ({@link toHttpStatus}) so the sender retries (5xx/429) or drops (4xx) correctly.
311
+ */
312
+ declare class ExecutorError extends Error {
313
+ readonly code: string;
314
+ readonly retriable: boolean;
315
+ readonly httpStatus?: number;
316
+ constructor(args: {
317
+ code: string;
318
+ message: string;
319
+ retriable: boolean;
320
+ status?: number;
321
+ });
322
+ toEnvelope(): ErrorEnvelope;
323
+ }
324
+ /**
325
+ * Map an error to an HTTP status. An explicit `status` is honored when it agrees with `retriable`
326
+ * (5xx/429 for retriable, 4xx for permanent); otherwise the family default is used: retriable → 503,
327
+ * permanent → 400.
328
+ */
329
+ declare function toHttpStatus(e: {
330
+ retriable: boolean;
331
+ httpStatus?: number;
332
+ }): number;
333
+ /** Serialize an envelope to a JSON body string. */
334
+ declare function jsonResponseBody(env: ErrorEnvelope): string;
335
+
336
+ export { type ClaimResult, type CreateExecutorOptions, DATA_HEADERS, type ErrorEnvelope, type Executor, type ExecutorContext, type ExecutorCredential, ExecutorError, type ExecutorHandler, type FetchCredentialOptions, type IdempotencyStore, MAX_SIG_ENTRIES, MemoryStore, type NodeReq, type NodeRes, type NormalizedRequest, type NormalizedResponse, SIGNING_SCHEME, type SignRequestInput, type SignedHeaders, type SignedParts, type StoredResult, type VerifyInput, type VerifyResult, buildCanonical, createExecutor, fetchExecutorCredential, jsonResponseBody, signRequest, toFetchHandler, toHttpStatus, toNodeHandler, unixSeconds, verify, verifySignatureHeader };