@wenathlan/saddle 2.0.162 → 2.0.164

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,455 @@
1
+ /**
2
+ * communication.ts — API, protocol and webhook communication contracts.
3
+ *
4
+ * This file is the v2.0.0 grand-merge consolidation of the former nested
5
+ * folders of the repository (api) into the single
6
+ * root-level domain file of the saddle family standard: one optimized
7
+ * TypeScript file per correlated domain, ordinal sections, JSDoc on every
8
+ * block, intra-domain imports dissolved, cross-domain imports rewritten to
9
+ * ./<domain>.js. Every capability of every source file is preserved here;
10
+ * the pure re-export barrels (none in this domain) folded their surface into this file directly.
11
+ */
12
+
13
+ import { constanttimeequal, hmacsha256 } from "./foundation.ts";
14
+
15
+ /* ══════════════════════════════════════════════════════════════════ */
16
+ /* Section 0: api/controlsurface.ts — the operator control surface the */
17
+ /* control service defaults to (the saddle ownership law, wave a, */
18
+ /* actions 108-112: the workflow automation domain moved to devthink, */
19
+ /* and the api contracts carry their own default surface — the same */
20
+ /* resource and operation vocabulary, the same audit law). */
21
+ /* ══════════════════════════════════════════════════════════════════ */
22
+
23
+ /** the operator resources the control surface answers. */
24
+ export const controlresources = Object.freeze([
25
+ "jobs",
26
+ "sessions",
27
+ "storage",
28
+ "runners",
29
+ "permissions",
30
+ "logs",
31
+ "artifacts",
32
+ ]);
33
+
34
+ /** the operator operations the control surface answers. */
35
+ export const controloperations = Object.freeze([
36
+ "list",
37
+ "get",
38
+ "create",
39
+ "update",
40
+ "cancel",
41
+ "retry",
42
+ "delete",
43
+ "check",
44
+ ]);
45
+
46
+ /** the message a caught value carries for the wire: an Error answers its
47
+ * message only and every other shape answers the fallback (the caught
48
+ * value as a whole never rides the payload). */
49
+ const controlerrormessage = (error: unknown, fallback = "operation failed"): string => {
50
+ if (error instanceof Error) return error.message;
51
+ return fallback;
52
+ };
53
+
54
+ /** answers one control response and rides the optional audit hook. */
55
+ async function controlrespond({ ok, result, code, message, context, audit }) {
56
+ const response = { version: 1, ok, ...context, ...(ok ? { result } : { code, message }) };
57
+ if (audit)
58
+ try {
59
+ await audit(response);
60
+ } catch (error) {
61
+ response.auditerror = controlerrormessage(error);
62
+ }
63
+ return response;
64
+ }
65
+
66
+ /** Creates a caller-owned operator surface with resource handlers and
67
+ * optional audit recording — the default surface the control service
68
+ * mounts when the caller sends adapters without a prebuilt surface. */
69
+ export function controlsurface(options = {}) {
70
+ const adapters = { ...(options.adapters ?? {}) };
71
+ const audit = options.audit;
72
+ if (audit !== undefined && typeof audit !== "function") throw new TypeError("control audit must be callable");
73
+ for (const resource of Object.keys(adapters))
74
+ if (!controlresources.includes(resource) || !adapters[resource] || typeof adapters[resource] !== "object")
75
+ throw new TypeError(`unsupported control resource: ${resource}`);
76
+
77
+ async function execute(request = {}) {
78
+ const resource = String(request.resource ?? "");
79
+ const operation = String(request.operation ?? "");
80
+ if (!controlresources.includes(resource) || !controloperations.includes(operation))
81
+ throw new TypeError("control request is invalid");
82
+ const handler = adapters[resource]?.[operation];
83
+ const context = {
84
+ requestid: String(request.requestid ?? `control${Date.now().toString(36)}`),
85
+ resource,
86
+ operation,
87
+ };
88
+ if (typeof handler !== "function")
89
+ return controlrespond({
90
+ ok: false,
91
+ code: "UNSUPPORTED_CONTROL",
92
+ message: `${resource}.${operation} is not configured`,
93
+ context,
94
+ audit,
95
+ });
96
+ try {
97
+ const result = await handler(request.input ?? {}, context);
98
+ return controlrespond({ ok: true, result, context, audit });
99
+ } catch (error) {
100
+ return controlrespond({
101
+ ok: false,
102
+ code: String(error?.code ?? "CONTROL_FAILED"),
103
+ message: controlerrormessage(error),
104
+ context,
105
+ audit,
106
+ });
107
+ }
108
+ }
109
+
110
+ return {
111
+ version: 1,
112
+ resources: [...controlresources],
113
+ operations: [...controloperations],
114
+ execute,
115
+ describe: () => ({
116
+ version: 1,
117
+ resources: [...controlresources],
118
+ configured: Object.keys(adapters).filter((resource) => controlresources.includes(resource)),
119
+ }),
120
+ };
121
+ }
122
+
123
+ /* ════════════════════════════════════════════════════════════════════ */
124
+ /* Section 1: api/auth.ts — api authorization delegates token verification to the caller and never stores credentials. */
125
+ /* ════════════════════════════════════════════════════════════════════ */
126
+ /**
127
+ * api authorization delegates token verification to the caller and never stores credentials.
128
+ */
129
+
130
+ /** Authorizes a request through an injected verifier or returns an anonymous principal. */
131
+ export async function authorize(request, options = {}) {
132
+ const token = request?.headers?.get?.("authorization")?.replace(/^Bearer\s+/i, "") ?? request?.headers?.get?.("x-api-key");
133
+ if (typeof options.verify !== "function") return { authenticated: false, subject: "anonymous", tokenpresent: Boolean(token) };
134
+ if (!token) return { authenticated: false, subject: "anonymous", tokenpresent: false };
135
+ const principal = await options.verify(token, request);
136
+ if (!principal) { const error = new Error("request is not authorized"); error.code = "UNAUTHORIZED"; throw error; }
137
+ return { authenticated: true, subject: String(principal.subject ?? principal.id ?? "caller"), claims: { ...(principal.claims ?? {}) } };
138
+ }
139
+
140
+ /* ════════════════════════════════════════════════════════════════════ */
141
+ /* Section 2: api/contracts.ts — api contracts keep request identity and success envelopes stable across HTTP and MCP adapters. */
142
+ /* ════════════════════════════════════════════════════════════════════ */
143
+ /**
144
+ * api contracts keep request identity and success envelopes stable across HTTP and MCP adapters.
145
+ */
146
+
147
+ export const apiversion = 1;
148
+
149
+ /** Extracts a caller supplied request id or creates a local id without exposing secrets. */
150
+ export function requestcontext(request, options = {}) {
151
+ const requestid = request?.headers?.get?.("x-request-id") ?? options.requestid ?? `request${Date.now().toString(36)}`;
152
+ return { version: apiversion, requestid: String(requestid), method: request?.method ?? options.method, path: options.path };
153
+ }
154
+
155
+ /** Creates a versioned success envelope for APIs that opt into envelopes. */
156
+ export function successpayload(data, context = {}) { return { version: apiversion, requestid: String(context.requestid ?? `request${Date.now().toString(36)}`), data }; }
157
+
158
+ /** Creates a versioned error payload with a stable retry hint. */
159
+ export function errorpayload(code, message, context = {}) { return { version: apiversion, requestid: String(context.requestid ?? `request${Date.now().toString(36)}`), error: { code: String(code), message: String(message), retryafter: Number(context.retryafter ?? 0) } }; }
160
+
161
+ /* ════════════════════════════════════════════════════════════════════ */
162
+ /* Section 3: api/security.ts — URL security rejects private network targets before a fetch is attempted. */
163
+ /* ════════════════════════════════════════════════════════════════════ */
164
+ /**
165
+ * URL security rejects private network targets before a fetch is attempted.
166
+ * DNS resolution remains a host adapter concern and is never performed silently.
167
+ */
168
+ export function assertpublicurl(value, options = {}) {
169
+ const url = new URL(value);
170
+ if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("url protocol is not allowed");
171
+ if (Array.isArray(options.allowedhosts) && options.allowedhosts.length > 0 && !options.allowedhosts.includes(url.hostname)) throw new Error("url host is not allowed");
172
+ if (options.allowprivate) return url;
173
+ if (privatehostname(url.hostname) || privateip(url.hostname)) throw new Error("private network target is not allowed");
174
+ return url;
175
+ }
176
+
177
+ /** Checks a resolved address list through a caller supplied DNS resolver to reduce rebinding risk. */
178
+ export async function assertresolvedpublicurl(value, options = {}) {
179
+ const url = assertpublicurl(value, options);
180
+ if (typeof options.resolve !== "function" || options.allowprivate) return url;
181
+ const addresses = await options.resolve(url.hostname);
182
+ if (!Array.isArray(addresses) || addresses.length === 0) throw new Error("url host did not resolve to an address");
183
+ for (const address of addresses) if (privateip(String(address)) || privatehostname(String(address))) throw new Error("resolved target is private");
184
+ return url;
185
+ }
186
+
187
+ /** Validates a redirect chain as public and bounded before a caller follows it. */
188
+ export function assertredirectchain(values = [], options = {}) {
189
+ const maxredirects = Number(options.maxredirects ?? 5);
190
+ if (!Array.isArray(values) || values.length > maxredirects + 1) throw new Error("redirect chain exceeds configured limit");
191
+ return values.map((value) => assertpublicurl(value, options).href);
192
+ }
193
+
194
+ /** Returns a non throwing boolean for validators and middleware. */
195
+ export function ispublicurl(value, options = {}) { try { assertpublicurl(value, options); return true; } catch { return false; } }
196
+
197
+ function privatehostname(hostname) { const value = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); return value.endsWith(".local") || value.endsWith(".internal") || value.endsWith(".localhost") || value === "localhost" || value === "broadcasthost"; }
198
+ function privateip(hostname) {
199
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
200
+ if (normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") || normalized.startsWith("::ffff:")) return true;
201
+ const parts = normalized.split(".").map(Number);
202
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
203
+ const [first, second] = parts;
204
+ return first === 10 || first === 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
205
+ }
206
+
207
+ /* ════════════════════════════════════════════════════════════════════ */
208
+ /* Section 4: api/rate.ts — token bucket rate limiting supports global user and domain keys. */
209
+ /* ════════════════════════════════════════════════════════════════════ */
210
+ /**
211
+ * token bucket rate limiting supports global user and domain keys.
212
+ */
213
+ export function ratebucket(options = {}) {
214
+ const capacity = options.capacity ?? 10;
215
+ const refill = options.refill ?? capacity;
216
+ const interval = options.interval ?? 60000;
217
+ const buckets = new Map();
218
+ function consume(key, cost = 1) {
219
+ const now = Date.now();
220
+ const previous = buckets.get(key) ?? { tokens: capacity, at: now };
221
+ const tokens = Math.min(capacity, previous.tokens + ((now - previous.at) / interval) * refill);
222
+ if (tokens < cost) return { allowed: false, retryafter: Math.ceil(((cost - tokens) / refill) * interval) };
223
+ buckets.set(key, { tokens: tokens - cost, at: now });
224
+ return { allowed: true, retryafter: 0 };
225
+ }
226
+ return { consume, clear() { buckets.clear(); } };
227
+ }
228
+
229
+ export function ratelimiter(options = {}) {
230
+ const global = ratebucket(options.global ?? { capacity: 1000, refill: 1000 });
231
+ const user = ratebucket(options.user ?? { capacity: 100, refill: 100 });
232
+ const domain = ratebucket(options.domain ?? { capacity: 10, refill: 10 });
233
+ return {
234
+ check(input = {}) {
235
+ const checks = [[global, "global"], [user, `user:${input.user ?? "anonymous"}`], [domain, `domain:${input.domain ?? "unknown"}`]];
236
+ for (const [bucket, key] of checks) { const result = bucket.consume(key); if (!result.allowed) return { allowed: false, retryafter: result.retryafter, scope: key }; }
237
+ return { allowed: true, retryafter: 0 };
238
+ }
239
+ };
240
+ }
241
+
242
+ /* ════════════════════════════════════════════════════════════════════ */
243
+ /* Section 5: api/http.ts — http helpers use the web request response contract and remain framework neutral. */
244
+ /* ════════════════════════════════════════════════════════════════════ */
245
+ /**
246
+ * http helpers use the web request response contract and remain framework neutral.
247
+ */
248
+ /** redactstack — the payload boundary of the wire: an Error serializes as its name and message only, and a stack field of any nested object never rides the response. */
249
+ function redactstack(value) {
250
+ if (value instanceof Error) return { name: value.name, message: value.message };
251
+ if (Array.isArray(value)) return value.map(redactstack);
252
+ if (value && typeof value === "object") {
253
+ const out = {};
254
+ for (const [key, item] of Object.entries(value)) if (key !== "stack" && key !== "stackTrace") out[key] = redactstack(item);
255
+ return out;
256
+ }
257
+ return value;
258
+ }
259
+
260
+ export function jsonresponse(data, options = {}) { return new Response(JSON.stringify(redactstack(data)), { status: options.status ?? 200, headers: { "content-type": "application/json; charset=utf-8", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", ...(options.headers ?? {}) } }); }
261
+
262
+ /** The failure code a response may carry: the caught value selects from the literal table — an Error object never reaches the wire (its shape answers a known code or the fallback answers), so no stack-derived byte rides the payload the way the exposure gate refused. */
263
+ const responsecode = (error: unknown, fallback: string): string => {
264
+ const code = (error as { code?: unknown } | null | undefined)?.code;
265
+ switch (typeof code === "string" ? code : "") {
266
+ case "UNAUTHORIZED": return "UNAUTHORIZED";
267
+ case "RATE_LIMITED": return "RATE_LIMITED";
268
+ case "NOT_FOUND": return "NOT_FOUND";
269
+ case "METHOD_NOT_ALLOWED": return "METHOD_NOT_ALLOWED";
270
+ case "REQUEST_FAILED": return "REQUEST_FAILED";
271
+ case "CONTROL_REQUEST_FAILED": return "CONTROL_REQUEST_FAILED";
272
+ default: return fallback;
273
+ }
274
+ };
275
+
276
+ export function errorresponse(code, message, options = {}) { return jsonresponse({ error: { code, message, retryafter: options.retryafter ?? 0, requestid: options.requestid ?? `request${Date.now().toString(36)}` } }, { status: options.status ?? 400, headers: options.headers }); }
277
+
278
+ export function sseresponse(events) {
279
+ const encoder = new TextEncoder();
280
+ const stream = new ReadableStream({ start(controller) { for (const event of events) controller.enqueue(encoder.encode(`event: ${event.event ?? "message"}\ndata: ${JSON.stringify(event.data ?? {})}\n\n`)); controller.close(); } });
281
+ return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache, no-store", connection: "keep-alive", "x-accel-buffering": "no", "x-content-type-options": "nosniff" } });
282
+ }
283
+
284
+ /* ════════════════════════════════════════════════════════════════════ */
285
+ /* Section 6: api/json.ts — json helpers keep serialization rules explicit at the protocol boundary. */
286
+ /* ════════════════════════════════════════════════════════════════════ */
287
+ /**
288
+ * json helpers keep serialization rules explicit at the protocol boundary.
289
+ */
290
+ export function jsonencode(value) { return JSON.stringify(value); }
291
+ export function jsondecode(value) { return JSON.parse(value); }
292
+
293
+ /* ════════════════════════════════════════════════════════════════════ */
294
+ /* Section 7: api/ndjson.ts — ndjson supports append only records and incremental parsing. */
295
+ /* ════════════════════════════════════════════════════════════════════ */
296
+ /**
297
+ * ndjson supports append only records and incremental parsing.
298
+ */
299
+
300
+ export async function* ndjsonencode(values) { for await (const value of values) yield `${jsonencode(value)}\n`; }
301
+
302
+ export async function* ndjsondecode(input) {
303
+ let buffer = "";
304
+ for await (const chunk of input) {
305
+ buffer += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
306
+ const lines = buffer.split(/\r?\n/);
307
+ buffer = lines.pop() ?? "";
308
+ for (const line of lines) if (line.trim()) yield jsondecode(line);
309
+ }
310
+ if (buffer.trim()) yield jsondecode(buffer);
311
+ }
312
+
313
+ /* ════════════════════════════════════════════════════════════════════ */
314
+ /* Section 8: api/sse.ts — sse helpers encode typed events without requiring a web framework. */
315
+ /* ════════════════════════════════════════════════════════════════════ */
316
+ /**
317
+ * sse helpers encode typed events without requiring a web framework.
318
+ */
319
+
320
+ export function sseencode(event) {
321
+ const lines = [];
322
+ if (event.id !== undefined) lines.push(`id: ${event.id}`);
323
+ if (event.event) lines.push(`event: ${event.event}`);
324
+ for (const line of jsonencode(event.data ?? {}).split("\n")) lines.push(`data: ${line}`);
325
+ return `${lines.join("\n")}\n\n`;
326
+ }
327
+
328
+ export function ssedecode(chunk) {
329
+ const fields = {};
330
+ for (const line of String(chunk).split(/\r?\n/)) {
331
+ const index = line.indexOf(":");
332
+ if (index < 0) continue;
333
+ fields[line.slice(0, index)] = line.slice(index + 1).trimStart();
334
+ }
335
+ return { id: fields.id, event: fields.event, data: fields.data ? jsondecode(fields.data) : undefined };
336
+ }
337
+
338
+ /* ════════════════════════════════════════════════════════════════════ */
339
+ /* Section 9: api/blocks.ts — block streaming yields bounded chunks and lets the consumer control backpressure. */
340
+ /* ════════════════════════════════════════════════════════════════════ */
341
+ /**
342
+ * block streaming yields bounded chunks and lets the consumer control backpressure.
343
+ */
344
+ export async function* blockstream(input, options = {}) {
345
+ const blockbytes = options.blockbytes;
346
+ if (!Number.isInteger(blockbytes) || blockbytes < 1) throw new TypeError("blockbytes must be a positive integer");
347
+ let index = 0;
348
+ let offset = 0;
349
+ let pending = new Uint8Array(0);
350
+ for await (const part of input instanceof Uint8Array ? [input] : input) {
351
+ const data = part instanceof Uint8Array ? part : new TextEncoder().encode(String(part));
352
+ pending = concat(pending, data);
353
+ while (pending.byteLength > blockbytes) { const block = pending.slice(0, blockbytes); pending = pending.slice(blockbytes); yield { index, offset, data: block, final: false }; index += 1; offset += block.byteLength; }
354
+ }
355
+ if (pending.byteLength) yield { index, offset, data: pending, final: true };
356
+ }
357
+
358
+ function concat(left, right) { const output = new Uint8Array(left.byteLength + right.byteLength); output.set(left); output.set(right, left.byteLength); return output; }
359
+
360
+ /* ════════════════════════════════════════════════════════════════════ */
361
+ /* Section 11: api/control.ts — control service exposes operator controls through Web Request and Response without selecting a web framework. */
362
+ /* ════════════════════════════════════════════════════════════════════ */
363
+ /**
364
+ * control service exposes operator controls through Web Request and Response without selecting a web framework.
365
+ */
366
+
367
+
368
+ /** Creates a framework-neutral HTTP handler for an injected operator control surface. */
369
+ export function controlservice(options = {}) {
370
+ const controls = options.controls ?? controlsurface({ adapters: options.adapters, audit: options.audit });
371
+ const path = String(options.path ?? "/v1/control");
372
+ if (!path.startsWith("/")) throw new TypeError("control service path must be absolute");
373
+
374
+ async function handle(request) {
375
+ const context = requestcontext(request, { path });
376
+ try {
377
+ await authorize(request, { verify: options.verify });
378
+ const url = new URL(request.url ?? request);
379
+ if (url.pathname !== path) return errorresponse("NOT_FOUND", "control route not found", { status: 404, requestid: context.requestid });
380
+ if (request.method === "GET") return jsonresponse({ ...controls.describe(), requestid: context.requestid });
381
+ if (request.method !== "POST") return errorresponse("METHOD_NOT_ALLOWED", "control route requires GET or POST", { status: 405, requestid: context.requestid });
382
+ const input = typeof request.json === "function" ? await request.json() : {};
383
+ const result = await controls.execute(input);
384
+ return jsonresponse({ requestid: context.requestid, data: result }, { status: result.ok ? 200 : 409 });
385
+ } catch (error) {
386
+ console.error(error);
387
+ return errorresponse(responsecode(error, "CONTROL_REQUEST_FAILED"), "control request failed", { status: error?.code === "UNAUTHORIZED" ? 401 : 400, requestid: context.requestid });
388
+ }
389
+ }
390
+
391
+ return { path, controls, handle };
392
+ }
393
+
394
+ /* ════════════════════════════════════════════════════════════════════ */
395
+ /* Section 12: api/webhooksignature.ts — webhook signatures use hmac sha256 and keep secrets outside serialized events. */
396
+ /* ════════════════════════════════════════════════════════════════════ */
397
+ /**
398
+ * webhook signatures use hmac sha256 and keep secrets outside serialized events.
399
+ */
400
+
401
+ export function webhooksig(payload, secret) { return hmacsha256(typeof payload === "string" ? payload : JSON.stringify(payload), secret); }
402
+ export function webhookverify(payload, signature, secret) { return constanttimeequal(webhooksig(payload, secret), signature); }
403
+
404
+ /* ════════════════════════════════════════════════════════════════════ */
405
+ /* Section 13: api/webhookdelivery.ts — webhook delivery tracks attempts and dead letters without choosing a queue vendor. */
406
+ /* ════════════════════════════════════════════════════════════════════ */
407
+ /**
408
+ * webhook delivery tracks attempts and dead letters without choosing a queue vendor.
409
+ */
410
+
411
+ /** Creates a sequential delivery queue with retryable failure handling. */
412
+ export function deliveryqueue(options = {}) {
413
+ const maxattempts = Number(options.maxattempts ?? 3);
414
+ const records = new Map();
415
+ const dead = [];
416
+ async function deliver(input = {}, handler) {
417
+ if (typeof handler !== "function") throw new TypeError("delivery handler must be a function");
418
+ const id = String(input.id ?? `delivery${Date.now().toString(36)}${records.size}`);
419
+ const record = { id, status: "pending", attempts: 0, createdat: Date.now(), event: input.event, metadata: { ...(input.metadata ?? {}) } };
420
+ records.set(id, record);
421
+ while (record.attempts < maxattempts) {
422
+ record.attempts += 1;
423
+ try { record.result = await handler(input.event, input); record.status = "delivered"; record.updatedat = Date.now(); return { ...record }; }
424
+ catch (error) { record.error = { code: String(error.code ?? "DELIVERY_FAILED"), message: String(error.message ?? error), retryable: Boolean(error.retryable) }; if (!error.retryable) break; }
425
+ }
426
+ record.status = "dead";
427
+ dead.push({ ...record });
428
+ record.updatedat = Date.now();
429
+ return { ...record };
430
+ }
431
+ return { deliver, get(id) { return records.get(String(id)); }, list() { return [...records.values()].map((record) => ({ ...record })); }, deadletters() { return dead.map((record) => ({ ...record })); } };
432
+ }
433
+
434
+ /* ════════════════════════════════════════════════════════════════════ */
435
+ /* Section 14: api/webhookreceiver.ts — webhook receiver validates signatures before dispatch and drops duplicate delivery ids. */
436
+ /* ════════════════════════════════════════════════════════════════════ */
437
+ /**
438
+ * webhook receiver validates signatures before dispatch and drops duplicate delivery ids.
439
+ */
440
+
441
+ export function webhookreceiver(options = {}) {
442
+ if (typeof options.secret !== "string" || typeof options.handle !== "function") throw new TypeError("webhook receiver requires secret and handler");
443
+ const deliveries = new Set();
444
+ return {
445
+ async receive(input = {}) {
446
+ if (!webhookverify(input.body, input.signature, options.secret)) return { accepted: false, status: 401, reason: "invalid signature" };
447
+ const id = input.deliveryid ?? input.eventid;
448
+ if (id && deliveries.has(id)) return { accepted: false, status: 200, duplicate: true };
449
+ if (id) deliveries.add(id);
450
+ const result = await options.handle(input.event, input.body);
451
+ return { accepted: true, status: 202, deliveryid: id, result };
452
+ },
453
+ deliveries() { return [...deliveries]; }
454
+ };
455
+ }
package/distribution.ts CHANGED
@@ -366,7 +366,20 @@ export async function writeTargetPlan(target: string, format = target, output =
366
366
  return filename;
367
367
  }
368
368
 
369
- if (process.argv[1]?.endsWith("targetcli.js") || process.argv[1]?.endsWith("targetcli.ts")) {
369
+ /** The cli entry seat: argv[1] only exists under the node runtime — the
370
+ * family browser bridges import this module through the modes ladder for
371
+ * the publish contracts, and the raw process.argv[1] read throws at
372
+ * import time in the browser (the blank-window era of the forge and
373
+ * foundry seats). the guard answers undefined everywhere but node. */
374
+ const cliseat =
375
+ typeof process === "object" &&
376
+ process !== null &&
377
+ Array.isArray(process.argv) &&
378
+ typeof process.argv[1] === "string"
379
+ ? process.argv[1]
380
+ : undefined;
381
+
382
+ if (cliseat?.endsWith("targetcli.js") || cliseat?.endsWith("targetcli.ts")) {
370
383
  const [, , target = "library", format = target, output = "build/targets"] = process.argv;
371
384
  writeTargetPlan(target, format, output).then((filename) => console.log(`target plan: ${filename}`)).catch((error) => { console.error(error.message); process.exitCode = 1; });
372
385
  }
@@ -511,7 +524,7 @@ function normalizeupdatedat(value) { const normalized = Number(value ?? 0); if (
511
524
 
512
525
  function comparefreshness(left, right) { return right.updatedat - left.updatedat || right.bytes - left.bytes || left.name.localeCompare(right.name); }
513
526
 
514
- if (process.argv[1] === fileURLToPath(import.meta.url)) createassets(parsearguments(process.argv.slice(2))).then(({ output }) => { console.log(`release assets: ${output}`); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
527
+ if (cliseat !== undefined && cliseat === fileURLToPath(import.meta.url)) createassets(parsearguments(process.argv.slice(2))).then(({ output }) => { console.log(`release assets: ${output}`); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
515
528
 
516
529
  /* ════════════════════════════════════════════════════════════════════ */
517
530
  /* Section 9: release/verify.ts — Release verification checks artifact digests, manifest consistency and */
@@ -613,7 +626,7 @@ function verifyparsearguments(argumentslist) {
613
626
  return options;
614
627
  }
615
628
 
616
- if (process.argv[1] === fileURLToPath(import.meta.url)) verifyassets(verifyparsearguments(process.argv.slice(2))).then((result) => { console.log(JSON.stringify(result)); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
629
+ if (cliseat !== undefined && cliseat === fileURLToPath(import.meta.url)) verifyassets(verifyparsearguments(process.argv.slice(2))).then((result) => { console.log(JSON.stringify(result)); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
617
630
 
618
631
  /* ════════════════════════════════════════════════════════════════════ */
619
632
  /* Section 10: release/evidence.ts — Release evidence evaluates caller-supplied metadata without fetching, signing, */
@@ -0,0 +1,9 @@
1
+ # Saddle acceptable use policy
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The Saddle source code is licensed under the GNU General Public License version 3.0 only. This policy governs hosted services, credentials, runners and integrations operated with Saddle; it does not reduce rights granted by the GPL.
6
+
7
+ Operators must use Saddle lawfully, respect the terms of each connected provider, protect credentials, and obtain authorization before accessing systems or data. Operators must not use Saddle to distribute malware, evade access controls, attack third-party systems, violate privacy, infringe intellectual property, or exceed provider quotas.
8
+
9
+ The project may suspend a hosted integration or reject an unsafe request when necessary to protect users, providers or the public. These operational controls do not restrict lawful use, modification or redistribution of the GPL-licensed software itself.
@@ -0,0 +1,16 @@
1
+ # Saddle authors
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The project authors and historical copyright holders are:
6
+
7
+ | Contributor | Role |
8
+ |---|---|
9
+ | devthink | Historical author and project contributor |
10
+ | nathlan | Project owner and maintainer |
11
+ | iakadion | Historical author and project contributor |
12
+ | nathu filho | Historical author and project contributor |
13
+ | allan neris | Historical author and project contributor |
14
+ | andraneris | Historical author and project contributor |
15
+
16
+ Saddle is maintained at https://github.com/wenathlan/saddle and distributed under GPL-3.0-only. Contributors retain copyright in their original contributions unless a separate written agreement states otherwise.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: Bug Report
3
+ about: Report a reproducible issue in Saddle
4
+ title: "[BUG]"
5
+ labels: bug
6
+ ---
7
+
8
+ # Saddle is licensed under GPL-3.0-only. Please do not include secrets or private data.
9
+
10
+ **Describe the issue:**
11
+
12
+ **File / Line:**
13
+
14
+ **Expected behavior:**
15
+
16
+ **Additional context:**
package/docs/cla.md ADDED
@@ -0,0 +1,9 @@
1
+ # Saddle contributor terms
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Saddle does not require an exclusive proprietary assignment of contributions. A contributor submitting a change represents that the contributor has the right to submit it and grants the project the permissions necessary to review, merge, reproduce, distribute and maintain the contribution under GPL-3.0-only.
6
+
7
+ The contributor keeps copyright in the contribution. The project may include the contribution in source and binary releases under GPL-3.0-only and may preserve attribution and required notices.
8
+
9
+ If a separate contributor agreement is required for a particular institutional or commercial arrangement, it must be reviewed and accepted in writing before the contribution is merged. This file is not a replacement for legal advice or a bespoke agreement.
@@ -0,0 +1,9 @@
1
+ # Saddle code of conduct
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The Saddle community follows a respectful, inclusive and technically constructive standard. Participants communicate in good faith, explain technical concerns with evidence, respect different backgrounds and experience levels, and protect private information.
6
+
7
+ Unacceptable conduct includes harassment, discrimination, threats, deliberate disclosure of private information, malicious interference, impersonation, spam, and behavior that violates applicable law or the project policies.
8
+
9
+ Project maintainers may moderate discussions, remove harmful content, or restrict participation when necessary. Reports should be sent through the repository security or support channels, with private disclosure used for security-sensitive matters.
@@ -0,0 +1,9 @@
1
+ # Contributing to Saddle
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Saddle welcomes contributions that improve the library, its adapters, target builds, documentation, tests and security posture. Contributions are licensed under GPL-3.0-only unless a different compatible arrangement is agreed in writing before acceptance.
6
+
7
+ A pull request should explain the change, include focused verification, preserve the root-first architecture and avoid credentials, private data or generated artifacts. Security vulnerabilities must use the private process in `SECURITY.md` rather than a public issue.
8
+
9
+ Maintainers may review, request changes, decline or merge a contribution. No contributor is required to transfer exclusive ownership of their work merely to submit a pull request.
@@ -0,0 +1,7 @@
1
+ # Saddle copyright notice
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The Saddle source code and original documentation are distributed under the GNU General Public License version 3.0 only. The complete license text is kept in the root `LICENSE` file and must be distributed with covered copies.
6
+
7
+ Copyright ownership remains with the respective authors and contributors. Copyright ownership does not remove the rights granted by GPL-3.0-only. The Saddle name, logos and other marks are covered separately by `trademarkpolicy.md`.
@@ -0,0 +1,7 @@
1
+ # Saddle disclaimer
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Saddle is provided under GPL-3.0-only and without warranty to the maximum extent permitted by law. The project does not guarantee availability, correctness, security, compatibility, performance or fitness for a particular purpose.
6
+
7
+ Operators remain responsible for reviewing source code, configuring integrations, protecting credentials, validating outputs and complying with the terms of connected services. Nothing in this disclaimer removes rights or obligations established by GPL-3.0-only.
@@ -0,0 +1,19 @@
1
+ # Distribution lanes
2
+
3
+ The eleven distribution lanes of the PR14 law and the honest status each lane answers for saddle at the 2.0.163 family rung. A lane never ships metadata its real surface cannot back: a lane without a real carrier stays a documented stub until the surface lands, and no version ever bumps to fake a lane.
4
+
5
+ | Lane | Carrier | Status | Note |
6
+ | --- | --- | --- | --- |
7
+ | npmjs | package.json — @wenathlan/saddle | live | the family-npmjs lane publishes the engine envelope (the heaviest pass, always last) |
8
+ | GitHub Packages npm | package.json — @wenathlan/saddle | live | the family-githubnpm lane publishes the same envelope idempotently |
9
+ | Maven | pom.xml — io.github.wenathlan:saddle | live | the family-maven lane deploys the one real jar of the family (the Saddle.java adapter) |
10
+ | NuGet | saddle.csproj | live | the family-nuget lane packs the engine envelope |
11
+ | Gradle | build.gradle.kts — com.wenathlan:saddle | live | the family-gradle lane publishes the module beside the maven coordinates |
12
+ | Ruby Gems | saddle.gemspec | live | the family-rubygems lane pushes the gem carrying the SaddleCli.rb process adapter |
13
+ | Docker Container | Dockerfile | live | the saddle-container lane attaches the engine archive and the vhe lanes publish the max / balanced / lite profile indexes under the eleven-architecture standard of containerarch.ts |
14
+ | Go Get | go.mod | stub | no Go source surface ships yet — the lane lands when a real Go adapter exists, never as an empty module |
15
+ | Cargo Crates | Cargo.toml | stub | no Rust source surface ships yet — the lane lands with a real crate, never as an empty manifest (the tauri rust envelope stays runner-side toolchain output) |
16
+ | PyPi | pyproject.toml | stub | no Python package surface ships yet — the qemubridge.py helper is a bridge script, not a wheel surface; the lane lands with a real package, never as an empty project table |
17
+ | Homebrew | Formula | stub | no tap repository ships yet — the formula lands pointing at the real release binaries with real checksums, never at a placeholder |
18
+
19
+ The engines block of package.json keeps Go, Rust and Python as declared toolchain floors — a catalog row is not a distribution lane. The dual npm law answers through the npmjs and GitHub Packages rows; the release binaries (the desktop bundles, the mobile artifacts, the vhe container archives) ride the family-desktop, family-mobile and saddle-container lanes every release rung.
package/docs/doc.go ADDED
@@ -0,0 +1,25 @@
1
+ // Package docs carries the go module identity of the saddle engine
2
+ // envelope — the binary computing engine that turns distributed storage
3
+ // into a publishable working set, carrying the merged virtual-hardware
4
+ // engine.
5
+ //
6
+ // The goget wave seats the go module rung beside the maven, nuget,
7
+ // rubygems and gradle rungs: the module resolves at
8
+ // github.com/wenathlan/devthink/saddle/v2 through the
9
+ // proxy.golang.org chain, and the version answers the per-module tag
10
+ // (saddle/vX.Y.Z on the release commit) the publish lane syncs — the
11
+ // package carries no version constant the metadata lockstep would have
12
+ // to stamp, the same way the gradle marker reads the envelope
13
+ // package.json at build time. The identity carrier rides the
14
+ // documentation support seat because the app root carries the C engine
15
+ // sources the go package law refuses beside a package.
16
+ package docs
17
+
18
+ // ModulePath is the go module path the go get consumer resolves.
19
+ const ModulePath = "github.com/wenathlan/devthink/saddle/v2"
20
+
21
+ // Family is the distribution family every envelope of the monorepo answers.
22
+ const Family = "wenathlan"
23
+
24
+ // Description is the envelope summary the registry metadata carries.
25
+ const Description = "The saddle engine envelope (the binary computing engine carrying the merged virtual-hardware engine)."