@wenathlan/saddle 2.0.163 → 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.
- package/Dockerfile +26 -3
- package/README.md +1 -1
- package/cli.ts +99 -2
- package/communication.ts +455 -0
- package/docs/distributionlanes.md +19 -0
- package/docs/doc.go +25 -0
- package/docs/ninetymodalities.md +8 -0
- package/docs/osdistributions.md +149 -0
- package/integration.ts +221 -0
- package/modalities.ts +4665 -0
- package/package.json +11 -5
- package/scriptlanes.ts +774 -0
package/Dockerfile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# saddle 2.0.
|
|
1
|
+
# saddle 2.0.163 — THE ENGINE CONTAINER FILE
|
|
2
2
|
#
|
|
3
3
|
# THE FAMILY CONTAINER DOCTRINE, THE SADDLE SHAPE: one versioned container
|
|
4
4
|
# per engine, built like the site homes (argan, cadria) build theirs — a
|
|
@@ -61,6 +61,29 @@
|
|
|
61
61
|
# is the sandbox spec surface the console answers, never a host
|
|
62
62
|
# distribution this container installs.
|
|
63
63
|
#
|
|
64
|
+
# THE OS DISTRIBUTION SEATS the saddle container carries (the osdistrib
|
|
65
|
+
# law, saddle/containerarch.ts — the owner's sentence: "iniciar
|
|
66
|
+
# distribuição do container saddle com por exemplo, grapheneos, omarchy
|
|
67
|
+
# e outros sistemas dentro do saddle"): the container starts its
|
|
68
|
+
# distribution catalog with the seven system seats the OSDISTRIBUTIONS
|
|
69
|
+
# registry pins — grapheneos (a privacy and security hardened version of
|
|
70
|
+
# android), omarchy (the arch linux-based distribution created by dhh),
|
|
71
|
+
# vanilla os (based on ubuntu), maru os (firmware, not an application),
|
|
72
|
+
# the v seat (the vlang platform the dependency catalog pins at 0.5.2,
|
|
73
|
+
# never a guest distribution), the normal linux baseline and android —
|
|
74
|
+
# every seat paired with the architecture legs the eleven-architecture
|
|
75
|
+
# standard already carries (the mobile and firmware seats ride the arm64
|
|
76
|
+
# legs, the desktop and baseline seats ride amd64 and arm64) and bound
|
|
77
|
+
# to the max/balanced/lite engine plan trio the engineplans table owns.
|
|
78
|
+
# The distribution matrix needs NO new build legs: the seats ride the
|
|
79
|
+
# existing arch axis of this file (the amd64 and arm64 legs the pinned
|
|
80
|
+
# base already resolves), so the publish lanes and the build matrix stay
|
|
81
|
+
# untouched — the registry is the catalog the container lanes stamp and
|
|
82
|
+
# the modality system reads (saddle/docs/osdistributions.md). The
|
|
83
|
+
# 50gb-per-plan example the owner's law names stays PLAN CONFIG the
|
|
84
|
+
# devthink plan logic stamps, never a container cap: the engines are
|
|
85
|
+
# unlimited.
|
|
86
|
+
#
|
|
64
87
|
# THE ELEVEN-ARCHITECTURE PLATFORM STANDARD (the pr15 law): every
|
|
65
88
|
# profile answers the eleven architecture seats the container standard
|
|
66
89
|
# names — the CONTAINERARCHIDS of saddle/containerarch.ts (linux-x64,
|
|
@@ -118,7 +141,7 @@ ARG NODE_BASE_DIGEST
|
|
|
118
141
|
# ===== stage: solbuild — the locked npm tree and the console spa =====
|
|
119
142
|
FROM --platform=$BUILDPLATFORM ${NODE_BASE_TAG}@sha256:${NODE_BASE_DIGEST} AS solbuild
|
|
120
143
|
|
|
121
|
-
ARG SADDLE_VERSION=2.0.
|
|
144
|
+
ARG SADDLE_VERSION=2.0.163
|
|
122
145
|
|
|
123
146
|
WORKDIR /app
|
|
124
147
|
|
|
@@ -146,7 +169,7 @@ RUN npm run web:build:pages \
|
|
|
146
169
|
# ===== stage: base — the hardened runtime floor every profile shares =====
|
|
147
170
|
FROM ${NODE_BASE_TAG}@sha256:${NODE_BASE_DIGEST} AS base
|
|
148
171
|
|
|
149
|
-
ARG SADDLE_VERSION=2.0.
|
|
172
|
+
ARG SADDLE_VERSION=2.0.163
|
|
150
173
|
# the revision stamp the devthink container carries (DEVTHINK_REVISION
|
|
151
174
|
# there): the publish legs pass the release sha at build time and the
|
|
152
175
|
# revision label below declares the default-unknown local shape.
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ domain modules land beside the saddle domains:
|
|
|
31
31
|
| `media.ts`, `render.ts`, `quantum.ts`, `tiers.ts` | media pipeline, GPU registry, quantum simulation, tier policy |
|
|
32
32
|
| `security.ts`, `alternatives.ts` | hardening gates and the alternative-stack catalog |
|
|
33
33
|
| `index.html`, `App.tsx`, `sol/` | the single SPA shell and the Sol theme (one tsx app with one stylesheet per theme) |
|
|
34
|
-
| `server.ts`, `sandbox.ts` | the zero-dependency self-hosted API of the 2.0.
|
|
34
|
+
| `server.ts`, `sandbox.ts` | the zero-dependency self-hosted API of the 2.0.164 grand-merge envelope (`node server.ts`; the api answers its version at `/api/v1/health`) and the browser-pure engine port |
|
|
35
35
|
| `vercel.json`, `netlify.toml` | the platform deploy adapters beside the surface they publish (the caddy ghost stays out of the tree) |
|
|
36
36
|
| `specs/*` package exports | processors, gpus, cores, boards and the qemu/mttg/passage/docker envelopes |
|
|
37
37
|
|
package/cli.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { mkdtemp, readFile } from "node:fs/promises";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
-
import { argv } from "node:process";
|
|
17
|
+
import { argv, stdin, stdout } from "node:process";
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
18
19
|
import { eventbus } from "./foundation.ts";
|
|
19
20
|
import { localmemory, localstorage } from "./virtual.ts";
|
|
20
21
|
import { engine, inprocess, scheduler } from "./execution.ts";
|
|
@@ -44,7 +45,8 @@ import {
|
|
|
44
45
|
export async function main(args = argv.slice(2)) {
|
|
45
46
|
const command = args[0] ?? "help";
|
|
46
47
|
/* Help stays short and lists only stable local commands. */
|
|
47
|
-
if (["help", "--help", "-h"].includes(command)) { console.log("saddle <command>\n\ncommands\n help\n modes\n plan [qemu|docker|toml]\n vm [qemu|docker|toml]\n vmlint\n gpus\n runexample\n script <run|background|status|docker|compile> [file|runid]\n mcp"); return; }
|
|
48
|
+
if (["help", "--help", "-h"].includes(command)) { console.log("saddle <command>\n\ncommands\n help\n chat\n modes\n plan [qemu|docker|toml]\n vm [qemu|docker|toml]\n vmlint\n gpus\n runexample\n script <run|background|status|docker|compile> [file|runid]\n mcp"); return; }
|
|
49
|
+
if (command === "chat") { await renderchat(); return; }
|
|
48
50
|
if (command === "modes") { console.log(JSON.stringify(modecatalog(), null, 2)); return; }
|
|
49
51
|
if (command === "plan") { await renderplan(args[1]); return; }
|
|
50
52
|
/* The vm/vmlint/gpus triad makes the nominal npm aliases (vm:create, */
|
|
@@ -271,4 +273,99 @@ async function renderscript(subcommand = "help", target = "") {
|
|
|
271
273
|
}
|
|
272
274
|
}
|
|
273
275
|
|
|
276
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
277
|
+
/* Section 4: the chat console — the pr13 creation workspace seat: the */
|
|
278
|
+
/* same windows 11 chat console the family clibin seats render, with */
|
|
279
|
+
/* the same tokens, the same titlebar and the same dpi ladder (native */
|
|
280
|
+
/* detection, 1x / 1.5x / 2x / 3x, never xdpi — the difference from */
|
|
281
|
+
/* the web / desktop surface is only the responsiveness). */
|
|
282
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
283
|
+
|
|
284
|
+
/** the chat accent of the prompt and the titlebar name (ansi 117 — the family accent #a3d7e6). */
|
|
285
|
+
const CHATACCENT = "\x1b[38;5;117m" as const;
|
|
286
|
+
/** the focus ink of the active top rule (ansi 75 — the family focus #4cc2ff). */
|
|
287
|
+
const CHATFOCUS = "\x1b[38;5;75m" as const;
|
|
288
|
+
/** the hover red of the unknown-command rows (ansi 203 — rgb(232, 17, 35)). */
|
|
289
|
+
const CHATHOVER = "\x1b[38;5;203m" as const;
|
|
290
|
+
/** the dim ink of the description rows (the minimal text law). */
|
|
291
|
+
const CHATINK = "\x1b[2m" as const;
|
|
292
|
+
/** the reset token that closes every colored run. */
|
|
293
|
+
const CHATRESET = "\x1b[0m" as const;
|
|
294
|
+
/** the single glyph of the rules and the rails. */
|
|
295
|
+
const CHATBAR = "─" as const;
|
|
296
|
+
/** the engine ownership one-liner of the seat, quoted from the README. */
|
|
297
|
+
const CHATENGINE = "storage-backed jobs, ninety sandbox modalities and portable runners — the master engine of virtualization (README)" as const;
|
|
298
|
+
/** the scale ladder the native detection resolves (the automatic scaling law). */
|
|
299
|
+
const CHATSCALESTEPS = [1, 1.5, 2, 3] as const;
|
|
300
|
+
|
|
301
|
+
/** the dpi frame the chat chrome renders inside: the resolved scale seat, the terminal columns and the rail length the titlebar draws. */
|
|
302
|
+
interface chatframe {
|
|
303
|
+
/** the scale seat of the ladder (1, 1.5, 2 or 3). */
|
|
304
|
+
scale: number;
|
|
305
|
+
/** the columns the terminal answers (windows terminal, conpty, xterm, tmux — every pty reports them). */
|
|
306
|
+
columns: number;
|
|
307
|
+
/** the titlebar rail length in cells (the pixel rail folded to the grid and clamped so the line never wraps). */
|
|
308
|
+
rail: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** resolves the dpi frame natively, zero dependencies: the columns come from stdout (every windows and linux terminal answers them), the scale seat from the column ladder — the explicit SADDLE_DPI environment override wins first (the native scaling knob). */
|
|
312
|
+
function chatdetectframe(): chatframe {
|
|
313
|
+
const columns = Number.isFinite(stdout.columns) && Number(stdout.columns) > 0 ? Number(stdout.columns) : 80;
|
|
314
|
+
const override = Number(process.env.SADDLE_DPI ?? "");
|
|
315
|
+
const scale =
|
|
316
|
+
Number.isFinite(override) && override > 0 && CHATSCALESTEPS.includes(override as (typeof CHATSCALESTEPS)[number])
|
|
317
|
+
? override
|
|
318
|
+
: columns >= 200
|
|
319
|
+
? 3
|
|
320
|
+
: columns >= 160
|
|
321
|
+
? 2
|
|
322
|
+
: columns >= 120
|
|
323
|
+
? 1.5
|
|
324
|
+
: 1;
|
|
325
|
+
const rail = Math.max(4, Math.min(Math.round(24 * scale), columns - "saddle".length - 16));
|
|
326
|
+
return { scale, columns, rail };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** runs the chat console: the titlebar, the top rule, the ❯ prompt and the dispatch (help, version, dpi, engine, exit) — the same chat look the web / desktop surfaces carry, an eof on stdin closing the console the same way exit does. */
|
|
330
|
+
async function renderchat(): Promise<void> {
|
|
331
|
+
const frame = chatdetectframe();
|
|
332
|
+
let seatversion = "0.0.0";
|
|
333
|
+
try {
|
|
334
|
+
seatversion = String((JSON.parse(await readFile(new URL("./package.json", import.meta.url), "utf8")) as { version?: string }).version ?? "0.0.0");
|
|
335
|
+
} catch {
|
|
336
|
+
/* the guarded best-effort read answers the zero literal the version row prints */
|
|
337
|
+
}
|
|
338
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
339
|
+
let eof = false;
|
|
340
|
+
rl.on("close", () => { eof = true; });
|
|
341
|
+
const rule = CHATBAR.repeat(Math.max(3, Math.round(3 * frame.scale)));
|
|
342
|
+
console.log(`${CHATACCENT}┌ saddle ─ cli${CHATRESET} ${CHATINK}${CHATBAR.repeat(frame.rail)} ─ ❐ ✕${CHATRESET}`);
|
|
343
|
+
console.log(`${CHATFOCUS}${rule} saddle ${rule}${CHATRESET}`);
|
|
344
|
+
console.log(`${CHATACCENT}saddle${CHATRESET} ${CHATINK}v${seatversion}${CHATRESET}`);
|
|
345
|
+
console.log(`${CHATINK}the saddle console — same design as web / desktop, only the DPI differs. type help.${CHATRESET}`);
|
|
346
|
+
rl.setPrompt(`${CHATACCENT}❯${CHATRESET} `);
|
|
347
|
+
rl.prompt();
|
|
348
|
+
for await (const raw of rl) {
|
|
349
|
+
const line = raw.trim();
|
|
350
|
+
const command = line.length > 0 ? line.split(/\s+/)[0]!.toLowerCase() : "";
|
|
351
|
+
if (command === "exit") rl.close();
|
|
352
|
+
else if (command === "help") {
|
|
353
|
+
console.log(`${CHATINK}commands${CHATRESET}`);
|
|
354
|
+
console.log(` help ${CHATINK}this list${CHATRESET}`);
|
|
355
|
+
console.log(` version ${CHATINK}the package version, read from package.json at runtime${CHATRESET}`);
|
|
356
|
+
console.log(` dpi ${CHATINK}the resolved dpi seat of this terminal (scale, columns, rail)${CHATRESET}`);
|
|
357
|
+
console.log(` engine ${CHATINK}the engine ownership one-liner of the app${CHATRESET}`);
|
|
358
|
+
console.log(` exit ${CHATINK}close the console${CHATRESET}`);
|
|
359
|
+
}
|
|
360
|
+
else if (command === "version") console.log(`${CHATACCENT}saddle${CHATRESET} ${seatversion}`);
|
|
361
|
+
else if (command === "dpi") console.log(`${CHATACCENT}saddle${CHATRESET} ${CHATINK}dpi seat ${frame.scale}x on ${frame.columns} columns — rail ${frame.rail} cells, ladder 1x / 1.5x / 2x / 3x${CHATRESET}`);
|
|
362
|
+
else if (command === "engine") console.log(`${CHATINK}${CHATENGINE}${CHATRESET}`);
|
|
363
|
+
else if (command.length > 0) console.log(`${CHATHOVER}✕${CHATRESET} ${CHATINK}unknown command: ${command} — type help${CHATRESET}`);
|
|
364
|
+
if (command === "exit") break;
|
|
365
|
+
/* the prompt is cosmetic chrome: a piped stdin that already reached eof skips it, the buffered lines still answer. */
|
|
366
|
+
if (!eof) rl.prompt();
|
|
367
|
+
}
|
|
368
|
+
console.log(`${CHATINK}saddle console closed.${CHATRESET}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
274
371
|
if (import.meta.url === `file://${argv[1]}`) main().catch((error) => { console.error(error); process.exitCode = 1; });
|
package/communication.ts
ADDED
|
@@ -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
|
+
}
|