@volter/world-core 2.0.37 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/app-route.cjs +12 -6
- package/app-route.d.cts +1 -1
- package/dist/app-route.cjs +12 -6
- package/dist/app-route.d.cts +1 -1
- package/dist/generated/pack-facts.json +1410 -3069
- package/dist/inject.cjs +64 -9
- package/dist/pack-facts.cjs +44 -0
- package/dist/src/actions.d.ts +3 -3
- package/dist/src/actions.js +22 -16
- package/dist/src/ancestry.d.ts +14 -2
- package/dist/src/ancestry.js +92 -2
- package/dist/src/anthropic-wire.d.ts +39 -0
- package/dist/src/anthropic-wire.js +136 -0
- package/dist/src/bytes.d.ts +7 -0
- package/dist/src/bytes.js +35 -0
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +0 -0
- package/dist/src/clickhouse/index.d.ts +3 -0
- package/dist/src/clickhouse/index.js +6 -0
- package/dist/src/clickhouse/sql.d.ts +233 -0
- package/dist/src/clickhouse/sql.js +4329 -0
- package/dist/src/clickhouse/types.d.ts +18 -0
- package/dist/src/clickhouse/types.js +47 -0
- package/dist/src/clickhouse/values.d.ts +146 -0
- package/dist/src/clickhouse/values.js +858 -0
- package/dist/src/client-bundle.js +2 -3
- package/dist/src/cors.d.ts +15 -0
- package/dist/src/cors.js +31 -0
- package/dist/src/derived-core.d.ts +487 -24
- package/dist/src/derived-core.js +788 -144
- package/dist/src/derived-real.d.ts +13 -0
- package/dist/src/derived-real.js +518 -0
- package/dist/src/derived.d.ts +35 -1
- package/dist/src/derived.js +61 -9
- package/dist/src/emit.js +1 -2
- package/dist/src/events.d.ts +206 -0
- package/dist/src/events.js +341 -0
- package/dist/src/executor.d.ts +3 -0
- package/dist/src/executor.js +19 -2
- package/dist/src/file-response.d.ts +6 -0
- package/dist/src/file-response.js +30 -0
- package/dist/src/fork.js +3 -2
- package/dist/src/git/history.d.ts +7 -0
- package/dist/src/git/history.js +24 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +28 -0
- package/dist/src/git/lfs.js +66 -0
- package/dist/src/git/objects.js +3 -8
- package/dist/src/git/smart-http.d.ts +3 -1
- package/dist/src/git/smart-http.js +67 -6
- package/dist/src/graphql-wire.d.ts +29 -0
- package/dist/src/graphql-wire.js +101 -0
- package/dist/src/grpc-wire.d.ts +67 -0
- package/dist/src/grpc-wire.js +170 -0
- package/dist/src/h2.d.ts +40 -0
- package/dist/src/h2.js +656 -0
- package/dist/src/head.d.ts +32 -3
- package/dist/src/head.js +161 -40
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +6 -6
- package/dist/src/hpack.json +1 -0
- package/dist/src/index.d.ts +64 -75
- package/dist/src/index.js +58 -101
- package/dist/src/log.js +28 -19
- package/dist/src/machines.d.ts +50 -0
- package/dist/src/machines.js +151 -0
- package/dist/src/managed-database.d.ts +86 -0
- package/dist/src/managed-database.js +283 -0
- package/dist/src/multipart.d.ts +11 -0
- package/dist/src/multipart.js +51 -0
- package/dist/src/observe.d.ts +15 -5
- package/dist/src/observe.js +23 -9
- package/dist/src/openai-wire.d.ts +108 -0
- package/dist/src/openai-wire.js +337 -0
- package/dist/src/pack-assets.d.ts +3 -4
- package/dist/src/pack-assets.js +15 -10
- package/dist/src/pack-fetch.d.ts +77 -0
- package/dist/src/pack-fetch.js +449 -0
- package/dist/src/pack-paths.d.ts +12 -0
- package/dist/src/pack-paths.js +86 -0
- package/dist/src/packRegistry.d.ts +69 -162
- package/dist/src/packRegistry.js +55 -20
- package/dist/src/people.d.ts +13 -0
- package/dist/src/people.js +18 -0
- package/dist/src/placeholder-image.d.ts +5 -0
- package/dist/src/placeholder-image.js +114 -0
- package/dist/src/protobuf.d.ts +28 -0
- package/dist/src/protobuf.js +332 -0
- package/dist/src/redis/engine.js +1 -1
- package/dist/src/request-scope.d.ts +1 -1
- package/dist/src/request-scope.js +6 -4
- package/dist/src/resource-blob.d.ts +5 -0
- package/dist/src/resource-blob.js +11 -0
- package/dist/src/runtime.d.ts +85 -0
- package/dist/src/runtime.js +104 -0
- package/dist/src/s3/wire.d.ts +60 -0
- package/dist/src/s3/wire.js +157 -0
- package/dist/src/scenario.d.ts +3 -0
- package/dist/src/scenario.js +2 -0
- package/dist/src/schema-sample.d.ts +1 -0
- package/dist/src/schema-sample.js +21 -0
- package/dist/src/sealed-box.d.ts +14 -0
- package/dist/src/sealed-box.js +225 -0
- package/dist/src/serve-http.d.ts +14 -0
- package/dist/src/serve-http.js +27 -3
- package/dist/src/serve.d.ts +6 -0
- package/dist/src/serve.js +69 -14
- package/dist/src/signing.d.ts +135 -0
- package/dist/src/signing.js +222 -0
- package/dist/src/sigv4.d.ts +48 -0
- package/dist/src/sigv4.js +167 -0
- package/dist/src/smtp.d.ts +16 -0
- package/dist/src/smtp.js +72 -0
- package/dist/src/sockets.d.ts +51 -0
- package/dist/src/sockets.js +90 -0
- package/dist/src/state-system.d.ts +1 -0
- package/dist/src/state-system.js +1 -1
- package/dist/src/storage.d.ts +1 -1
- package/dist/src/storage.js +3 -3
- package/dist/src/trace-context.js +1 -1
- package/dist/src/twin-fetch.d.ts +0 -7
- package/dist/src/twin-fetch.js +0 -14
- package/dist/src/vendor-call.d.ts +6 -0
- package/dist/src/vendor-call.js +41 -0
- package/dist/src/world-store.js +1 -1
- package/dist/vendor-hosts.cjs +36 -125
- package/dist/vendor-hosts.d.cts +8 -0
- package/generated/pack-facts.json +1410 -3069
- package/inject.cjs +64 -9
- package/pack-facts.cjs +44 -0
- package/package.json +17 -3
- package/src/actions.ts +23 -16
- package/src/ancestry.ts +74 -2
- package/src/anthropic-wire.ts +137 -0
- package/src/bytes.ts +42 -0
- package/src/changeset.ts +5 -5
- package/src/clickhouse/index.ts +6 -0
- package/src/clickhouse/sql.ts +3059 -0
- package/src/clickhouse/types.ts +44 -0
- package/src/clickhouse/values.ts +697 -0
- package/src/client-bundle.ts +2 -3
- package/src/cors.ts +34 -0
- package/src/derived-core.ts +1013 -146
- package/src/derived-real.ts +434 -0
- package/src/derived.ts +73 -3
- package/src/emit.ts +1 -2
- package/src/events.ts +449 -0
- package/src/executor.ts +24 -2
- package/src/file-response.ts +27 -0
- package/src/fork.ts +3 -2
- package/src/git/history.ts +19 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +67 -0
- package/src/git/objects.ts +3 -5
- package/src/git/smart-http.ts +56 -6
- package/src/graphql-wire.ts +106 -0
- package/src/grpc-wire.ts +159 -0
- package/src/h2.ts +627 -0
- package/src/head.ts +132 -41
- package/src/history.ts +6 -6
- package/src/hpack.json +1 -0
- package/src/index.ts +82 -329
- package/src/log.ts +27 -18
- package/src/machines.ts +151 -0
- package/src/managed-database.ts +299 -0
- package/src/multipart.ts +51 -0
- package/src/observe.ts +31 -15
- package/src/openai-wire.ts +371 -0
- package/src/pack-assets.ts +15 -11
- package/src/pack-fetch.ts +458 -0
- package/src/pack-paths.ts +72 -0
- package/src/packRegistry.ts +79 -167
- package/src/people.ts +31 -0
- package/src/placeholder-image.ts +88 -0
- package/src/protobuf.ts +251 -0
- package/src/redis/engine.ts +1 -1
- package/src/request-scope.ts +8 -4
- package/src/resource-blob.ts +13 -0
- package/src/runtime.ts +344 -0
- package/src/s3/wire.ts +172 -0
- package/src/scenario.ts +4 -0
- package/src/schema-sample.ts +24 -0
- package/src/sealed-box.ts +182 -0
- package/src/serve-http.ts +31 -3
- package/src/serve.ts +58 -14
- package/src/signing.ts +231 -0
- package/src/sigv4.ts +158 -0
- package/src/smtp.ts +76 -0
- package/src/sockets.ts +140 -0
- package/src/state-system.ts +2 -2
- package/src/storage.ts +3 -3
- package/src/trace-context.ts +1 -1
- package/src/twin-fetch.ts +0 -20
- package/src/vendor-call.ts +41 -0
- package/src/world-store.ts +1 -1
- package/vendor-hosts.cjs +36 -125
- package/vendor-hosts.d.cts +8 -0
- package/dist/src/mirror-shell.d.ts +0 -2
- package/dist/src/mirror-shell.js +0 -13
- package/dist/src/v1-removed.d.ts +0 -159
- package/dist/src/v1-removed.js +0 -124
- package/src/mirror-shell.ts +0 -15
- package/src/v1-removed.ts +0 -172
package/src/s3/wire.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// S3'S WIRE — the XML bodies S3 answers and reads, a bucket's CORS and lifecycle documents, a delete batch, an object's
|
|
2
|
+
// ETag, and a request's body kept as its bytes (docs/contributing/architecture.md, "Other wires": S3's wire is a kernel
|
|
3
|
+
// library, as Redis's command core is). Every vendor that speaks S3's API speaks this wire: AWS S3 itself, Cloudflare
|
|
4
|
+
// R2, and the S3-compatible stores that follow. What differs between them (where a request's account and bucket are,
|
|
5
|
+
// what they refuse, what they implement) is each pack's own. Moved here from the cloudflare pack's R2 lane, which had it
|
|
6
|
+
// ported from the aws pack's S3 code; a pack may import neither (B4). Importing this module does nothing.
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
// ── XML ──────────────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const DECL = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
12
|
+
const NS = 'http://s3.amazonaws.com/doc/2006-03-01/';
|
|
13
|
+
export function esc(s: string | number): string {
|
|
14
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
15
|
+
}
|
|
16
|
+
/** An element and its children or text. */
|
|
17
|
+
export const x = (tag: string, inner: string | number | string[]): string => `<${tag}>${Array.isArray(inner) ? inner.join('') : esc(inner)}</${tag}>`;
|
|
18
|
+
export const doc = (root: string, inner: string[]): string => `${DECL}<${root} xmlns="${NS}">${inner.join('')}</${root}>`;
|
|
19
|
+
export const xmlAnswer = (body: string, status = 200, headers: Record<string, string> = {}): Response => new Response(body, { status, headers: { 'content-type': 'application/xml', ...headers } });
|
|
20
|
+
|
|
21
|
+
export function unesc(s: string): string {
|
|
22
|
+
return s.replace(/&(?:lt|gt|quot|apos|amp|#\d+|#x[0-9a-fA-F]+);/g, decodeEntity);
|
|
23
|
+
}
|
|
24
|
+
/** One XML entity, named or numeric, as its character. */
|
|
25
|
+
function decodeEntity(m: string): string {
|
|
26
|
+
const named: Record<string, string> = { '<': '<', '>': '>', '"': '"', ''': "'", '&': '&' };
|
|
27
|
+
if (named[m]) return named[m]!;
|
|
28
|
+
const body = m.slice(2, -1);
|
|
29
|
+
const code = body[0] === 'x' ? Number.parseInt(body.slice(1), 16) : Number(body);
|
|
30
|
+
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
|
|
31
|
+
}
|
|
32
|
+
/** Every element of a tag's text in an XML body, unescaped. */
|
|
33
|
+
export const allOf = (xml: string, tag: string): string[] => [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'))].map((m) => unesc(m[1]!.trim()));
|
|
34
|
+
/** The first. */
|
|
35
|
+
export const oneOf = (xml: string, tag: string): string | undefined => allOf(xml, tag)[0];
|
|
36
|
+
|
|
37
|
+
// ── CORS (https://developers.cloudflare.com/r2/buckets/cors/) ──────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export type CorsRule = { id?: string; allowedOrigins: string[]; allowedMethods: string[]; allowedHeaders: string[]; exposeHeaders: string[]; maxAgeSeconds?: number };
|
|
40
|
+
|
|
41
|
+
export function parseCors(xml: string): CorsRule[] {
|
|
42
|
+
return [...xml.matchAll(/<CORSRule>([\s\S]*?)<\/CORSRule>/g)].map((m) => {
|
|
43
|
+
const b = m[1]!;
|
|
44
|
+
const id = oneOf(b, 'ID');
|
|
45
|
+
const age = oneOf(b, 'MaxAgeSeconds');
|
|
46
|
+
return { ...(id ? { id } : {}), allowedOrigins: allOf(b, 'AllowedOrigin'), allowedMethods: allOf(b, 'AllowedMethod'), allowedHeaders: allOf(b, 'AllowedHeader'), exposeHeaders: allOf(b, 'ExposeHeader'), ...(age ? { maxAgeSeconds: Number(age) } : {}) };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function corsXml(rules: CorsRule[]): string {
|
|
50
|
+
return doc('CORSConfiguration', rules.map((r) => x('CORSRule', [
|
|
51
|
+
...(r.id ? [x('ID', r.id)] : []), ...r.allowedOrigins.map((o) => x('AllowedOrigin', o)), ...r.allowedMethods.map((m) => x('AllowedMethod', m)),
|
|
52
|
+
...r.allowedHeaders.map((h) => x('AllowedHeader', h)), ...r.exposeHeaders.map((h) => x('ExposeHeader', h)), ...(r.maxAgeSeconds !== undefined ? [x('MaxAgeSeconds', r.maxAgeSeconds)] : []),
|
|
53
|
+
])));
|
|
54
|
+
}
|
|
55
|
+
function wildMatch(pattern: string, value: string, fold: boolean): boolean {
|
|
56
|
+
const p = fold ? pattern.toLowerCase() : pattern;
|
|
57
|
+
const v = fold ? value.toLowerCase() : value;
|
|
58
|
+
const star = p.indexOf('*');
|
|
59
|
+
if (star < 0) return p === v;
|
|
60
|
+
return v.length >= p.length - 1 && v.startsWith(p.slice(0, star)) && v.endsWith(p.slice(star + 1));
|
|
61
|
+
}
|
|
62
|
+
/** The first rule that allows the origin, the method and the headers a preflight asks for. */
|
|
63
|
+
export function corsRuleFor(rules: CorsRule[], origin: string, method: string, requested: string[]): CorsRule | undefined {
|
|
64
|
+
return rules.find((r) => r.allowedOrigins.some((o) => wildMatch(o, origin, false))
|
|
65
|
+
&& r.allowedMethods.some((m) => m === '*' || m.toUpperCase() === method.toUpperCase())
|
|
66
|
+
&& requested.every((h) => r.allowedHeaders.some((a) => wildMatch(a, h, true))));
|
|
67
|
+
}
|
|
68
|
+
/** The Access-Control-* headers a matching rule gives an answer ("AllowedOrigins: Specifies the value for the
|
|
69
|
+
* Access-Control-Allow-Origin header", and likewise for the methods, headers, exposed headers and max age). */
|
|
70
|
+
export function corsHeaders(rule: CorsRule, origin: string, requested: string[], preflight: boolean): Record<string, string> {
|
|
71
|
+
return {
|
|
72
|
+
'access-control-allow-origin': origin,
|
|
73
|
+
'access-control-allow-methods': rule.allowedMethods.join(', '),
|
|
74
|
+
vary: 'Origin, Access-Control-Request-Headers, Access-Control-Request-Method',
|
|
75
|
+
...(preflight && requested.length ? { 'access-control-allow-headers': requested.join(', ') } : {}),
|
|
76
|
+
...(preflight && rule.maxAgeSeconds !== undefined ? { 'access-control-max-age': String(rule.maxAgeSeconds) } : {}),
|
|
77
|
+
...(rule.exposeHeaders.length ? { 'access-control-expose-headers': rule.exposeHeaders.join(', ') } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export const requestedHeaders = (request: Request): string[] => (request.headers.get('access-control-request-headers') ?? '').split(',').map((h) => h.trim()).filter(Boolean);
|
|
81
|
+
|
|
82
|
+
// ── lifecycle (https://developers.cloudflare.com/r2/buckets/object-lifecycles/) ──────────────────
|
|
83
|
+
|
|
84
|
+
/** A rule's prefix is sent in its Filter or, in S3's older form, on the rule itself, or not at all (R2's lifecycle page
|
|
85
|
+
* sends a transition rule with neither, for the whole bucket); it is answered as it was sent. A
|
|
86
|
+
* transition moves an object to Infrequent Access (Object lifecycles: "transition objects to Infrequent Access storage
|
|
87
|
+
* after 30 days", its example `Transitions: [{ Days: 30, StorageClass: "STANDARD_IA" }]`). */
|
|
88
|
+
export type LifecycleRule = { id: string; enabled: boolean; prefix: string; prefixOnRule?: boolean; noPrefix?: boolean; expireDays?: number; expireDate?: string; abortMultipartDays?: number; transitions?: Array<{ days?: number; date?: string; storageClass: string }> };
|
|
89
|
+
|
|
90
|
+
export function parseLifecycle(xml: string): LifecycleRule[] {
|
|
91
|
+
return [...xml.matchAll(/<Rule>([\s\S]*?)<\/Rule>/g)].map((m) => {
|
|
92
|
+
const b = m[1]!;
|
|
93
|
+
const expiration = /<Expiration>([\s\S]*?)<\/Expiration>/.exec(b)?.[1];
|
|
94
|
+
const abort = /<AbortIncompleteMultipartUpload>([\s\S]*?)<\/AbortIncompleteMultipartUpload>/.exec(b)?.[1];
|
|
95
|
+
const days = expiration ? oneOf(expiration, 'Days') : undefined;
|
|
96
|
+
const date = expiration ? oneOf(expiration, 'Date') : undefined;
|
|
97
|
+
const after = abort ? oneOf(abort, 'DaysAfterInitiation') : undefined;
|
|
98
|
+
const transitions = [...b.matchAll(/<Transition>([\s\S]*?)<\/Transition>/g)].map((x) => {
|
|
99
|
+
const d = oneOf(x[1]!, 'Days');
|
|
100
|
+
const on = oneOf(x[1]!, 'Date');
|
|
101
|
+
return { ...(d !== undefined ? { days: Number(d) } : {}), ...(on !== undefined ? { date: on } : {}), storageClass: oneOf(x[1]!, 'StorageClass') ?? '' };
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
id: oneOf(b, 'ID') ?? '', enabled: (oneOf(b, 'Status') ?? 'Enabled') === 'Enabled', prefix: oneOf(b, 'Prefix') ?? '', ...(/<Filter>/.test(b) ? {} : /<Prefix>/.test(b) ? { prefixOnRule: true } : { noPrefix: true }),
|
|
105
|
+
...(days ? { expireDays: Number(days) } : {}), ...(date ? { expireDate: date } : {}), ...(after ? { abortMultipartDays: Number(after) } : {}), ...(transitions.length ? { transitions } : {}),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function lifecycleXml(rules: LifecycleRule[]): string {
|
|
110
|
+
return doc('LifecycleConfiguration', rules.map((r) => x('Rule', [
|
|
111
|
+
x('ID', r.id), ...(r.noPrefix ? [] : [r.prefixOnRule ? x('Prefix', r.prefix) : x('Filter', [x('Prefix', r.prefix)])]), x('Status', r.enabled ? 'Enabled' : 'Disabled'),
|
|
112
|
+
...(r.transitions ?? []).map((tr) => x('Transition', [...(tr.date !== undefined ? [x('Date', tr.date)] : [x('Days', tr.days ?? 0)]), x('StorageClass', tr.storageClass)])),
|
|
113
|
+
...(r.expireDays !== undefined ? [x('Expiration', [x('Days', r.expireDays)])] : r.expireDate !== undefined ? [x('Expiration', [x('Date', r.expireDate)])] : []),
|
|
114
|
+
...(r.abortMultipartDays !== undefined ? [x('AbortIncompleteMultipartUpload', [x('DaysAfterInitiation', r.abortMultipartDays)])] : []),
|
|
115
|
+
])));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── bodies ───────────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export function parseDelete(xml: string): { keys: string[]; quiet: boolean } {
|
|
121
|
+
return { keys: [...xml.matchAll(/<Object>([\s\S]*?)<\/Object>/g)].map((m) => oneOf(m[1]!, 'Key') ?? ''), quiet: /<Quiet>\s*true\s*<\/Quiet>/i.test(xml) };
|
|
122
|
+
}
|
|
123
|
+
export const locationConstraintOf = (xml: string): string | undefined => oneOf(xml, 'LocationConstraint');
|
|
124
|
+
|
|
125
|
+
export const md5Etag = (bytes: Uint8Array): string => `"${createHash('md5').update(bytes).digest('hex')}"`;
|
|
126
|
+
|
|
127
|
+
/** A request's body as the bytes it carried. The derived dispatch reads a body as text to find its parameters, which no
|
|
128
|
+
* binary object survives, so the lane's fetch reads the bytes first and keeps them here for the handler. */
|
|
129
|
+
const BODIES = new WeakMap<Request, Uint8Array>();
|
|
130
|
+
export async function withBytes(request: Request): Promise<Request> {
|
|
131
|
+
if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') return request;
|
|
132
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
133
|
+
const kept = new Request(request.url, { method: request.method, headers: request.headers, body: bytes });
|
|
134
|
+
BODIES.set(kept, bytes);
|
|
135
|
+
return kept;
|
|
136
|
+
}
|
|
137
|
+
export const bytesOf = (request: Request): Uint8Array => BODIES.get(request) ?? new Uint8Array(0);
|
|
138
|
+
|
|
139
|
+
/** Concatenate exact wire bytes; no text conversion is involved in multipart assembly. */
|
|
140
|
+
export function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
141
|
+
const out = new Uint8Array(parts.reduce((size, part) => size + part.byteLength, 0));
|
|
142
|
+
let offset = 0;
|
|
143
|
+
for (const part of parts) { out.set(part, offset); offset += part.byteLength; }
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
export type ChecksumAlgorithm = 'MD5' | 'SHA1' | 'SHA256' | 'CRC32' | 'CRC32C' | 'CRC64NVME';
|
|
147
|
+
/** S3's base64 digest; CRC bytes are big-endian on the wire. No streaming framing is decoded here. */
|
|
148
|
+
export function checksum(algorithm: ChecksumAlgorithm, bytes: Uint8Array): string {
|
|
149
|
+
if (algorithm === 'MD5' || algorithm === 'SHA1' || algorithm === 'SHA256') {
|
|
150
|
+
return createHash(algorithm.toLowerCase()).update(bytes).digest('base64');
|
|
151
|
+
}
|
|
152
|
+
if (algorithm === 'CRC64NVME') {
|
|
153
|
+
// AWS's reflected NVME polynomial: aws-checksums/tests/crc64_test.c.
|
|
154
|
+
let crc = 0xffffffffffffffffn;
|
|
155
|
+
for (const byte of bytes) {
|
|
156
|
+
crc ^= BigInt(byte);
|
|
157
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >> 1n) ^ (crc & 1n ? 0x9a6c9329ac4bc9b5n : 0n);
|
|
158
|
+
}
|
|
159
|
+
const out = new Uint8Array(8);
|
|
160
|
+
new DataView(out.buffer).setBigUint64(0, crc ^ 0xffffffffffffffffn, false);
|
|
161
|
+
return Buffer.from(out).toString('base64');
|
|
162
|
+
}
|
|
163
|
+
const polynomial = algorithm === 'CRC32C' ? 0x82f63b78 : 0xedb88320;
|
|
164
|
+
let crc = 0xffffffff;
|
|
165
|
+
for (const byte of bytes) {
|
|
166
|
+
crc ^= byte;
|
|
167
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? polynomial : 0);
|
|
168
|
+
}
|
|
169
|
+
const out = new Uint8Array(4);
|
|
170
|
+
new DataView(out.buffer).setUint32(0, (crc ^ 0xffffffff) >>> 0, false);
|
|
171
|
+
return Buffer.from(out).toString('base64');
|
|
172
|
+
}
|
package/src/scenario.ts
CHANGED
|
@@ -42,6 +42,9 @@ export type PackScenarioAdapter<Req> = {
|
|
|
42
42
|
* This is ALSO where a pack refuses success-shaped handlers on stateful routes
|
|
43
43
|
* ("seed that through the vendor's API instead"). */
|
|
44
44
|
validateRespond?: (respond: unknown, handler: ScenarioHandler) => string | null;
|
|
45
|
+
/** Whether the request admits a handler's answer (OpenAI's `tool_choice: 'none'` admits no tool call): a handler whose
|
|
46
|
+
* answer it does not admit is passed over, and the next that matches answers, else the stub. Absent, every answer. */
|
|
47
|
+
admits?: (req: Req, respond: unknown) => boolean;
|
|
45
48
|
/** The request's text corpus for `textPattern` extractors (packs with text requests). */
|
|
46
49
|
text?: (req: Req) => string;
|
|
47
50
|
/** Pack-defined extractor KINDS beyond the builtins (feature, textPattern). A pack kind with
|
|
@@ -303,6 +306,7 @@ export class ScenarioEngine<Req> {
|
|
|
303
306
|
if (r.handler.phase !== undefined && scope.phase !== r.handler.phase) continue;
|
|
304
307
|
if (r.handler.once && scope.onceFired.has(r.id)) continue;
|
|
305
308
|
if (!this.matches(r.handler, req, scope)) continue;
|
|
309
|
+
if ("respond" in r.handler && this.adapter.admits && !this.adapter.admits(req, r.handler.respond)) continue;
|
|
306
310
|
if (r.handler.once) scope.onceFired.add(r.id);
|
|
307
311
|
if (r.handler.advancePhase !== undefined) scope.phase = r.handler.advancePhase;
|
|
308
312
|
r.matches += 1;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A value a schema admits, for a pack that must answer structured output without running a model (a generative pack's
|
|
2
|
+
// labeled stub: the Claude API's JSON-schema output format, Gemini's responseSchema). The schema is JSON Schema, or the
|
|
3
|
+
// OpenAPI subset of it some vendors take (Gemini's upper-case type names, OBJECT and STRING, read as JSON Schema's). The
|
|
4
|
+
// value is the first the schema allows: an enum's first member, a const, the first of anyOf/oneOf, every property of an
|
|
5
|
+
// object, one item of an array, a number's minimum (else 0), false, and for a string a labeled text (a date-time, an
|
|
6
|
+
// email or a URI in its format). Importing it does nothing.
|
|
7
|
+
type Row = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export function sampleFromSchema(schema: unknown, name = 'value'): unknown {
|
|
10
|
+
const s = (schema && typeof schema === 'object' ? schema : {}) as Row;
|
|
11
|
+
if (Array.isArray(s.enum) && s.enum.length) return s.enum[0];
|
|
12
|
+
if (s.const !== undefined) return s.const;
|
|
13
|
+
for (const k of ['anyOf', 'oneOf', 'allOf']) if (Array.isArray(s[k]) && (s[k] as unknown[]).length) return sampleFromSchema((s[k] as unknown[])[0], name);
|
|
14
|
+
const raw = Array.isArray(s.type) ? (s.type as string[]).find((t) => String(t).toLowerCase() !== 'null') : s.type;
|
|
15
|
+
const type = typeof raw === 'string' ? raw.toLowerCase() : s.properties ? 'object' : s.items ? 'array' : undefined;
|
|
16
|
+
switch (type) {
|
|
17
|
+
case 'object': return Object.fromEntries(Object.entries((s.properties as Row | undefined) ?? {}).map(([k, v]) => [k, sampleFromSchema(v, k)]));
|
|
18
|
+
case 'array': return [sampleFromSchema(s.items, name)];
|
|
19
|
+
case 'integer': case 'number': return typeof s.minimum === 'number' ? s.minimum : 0;
|
|
20
|
+
case 'boolean': return false;
|
|
21
|
+
case 'null': return null;
|
|
22
|
+
default: return s.format === 'date-time' ? '2026-01-01T00:00:00Z' : s.format === 'email' ? 'twin@example.com' : s.format === 'uri' ? 'https://example.com/' : `twin-stub ${name}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// SEALED BOXES — libsodium's `crypto_box_seal` opened, for a vendor whose API takes a secret encrypted to a public key it
|
|
2
|
+
// published (GitHub's Actions secrets: "Secrets must be encrypted using the libsodium sealed box", with the repository's
|
|
3
|
+
// public key; `gh secret set` does it). The twin holds the key pair, derived from a seed, so it can decrypt what its
|
|
4
|
+
// runner must receive, as the vendor does. Everything here is deterministic: X25519 from Node, and HSalsa20, XSalsa20,
|
|
5
|
+
// Poly1305 and BLAKE2b written from their specifications (the kernel takes no dependency for them: B1).
|
|
6
|
+
//
|
|
7
|
+
// A sealed box is `ephemeral_pk (32) || mac (16) || ciphertext`; its nonce is BLAKE2b-192(ephemeral_pk || recipient_pk),
|
|
8
|
+
// its key crypto_box_beforenm(ephemeral_pk, recipient_sk) = HSalsa20(X25519(sk, epk), 0), and its body XSalsa20-Poly1305
|
|
9
|
+
// (https://doc.libsodium.org/public-key_cryptography/sealed_boxes, "Algorithm details").
|
|
10
|
+
// a namespace, never named imports: signing.ts reaches this from the kernel a browser client bundles, and the browser's
|
|
11
|
+
// `node:crypto` polyfill has no X25519 (signing.ts, the same rule)
|
|
12
|
+
import * as nodeCrypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
const PKCS8_X25519 = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
15
|
+
const SPKI_X25519 = Buffer.from('302a300506032b656e032100', 'hex');
|
|
16
|
+
|
|
17
|
+
/** A key pair from a seed: the secret key is SHA-256 of the seed (X25519 clamps it), the public key its X25519 point. */
|
|
18
|
+
export function sealedBoxKeyPair(seed: string): { publicKey: Uint8Array; secretKey: Uint8Array } {
|
|
19
|
+
const secretKey = new Uint8Array(nodeCrypto.createHash('sha256').update(`sealed-box:${seed}`).digest());
|
|
20
|
+
const priv = nodeCrypto.createPrivateKey({ key: Buffer.concat([PKCS8_X25519, secretKey]), format: 'der', type: 'pkcs8' });
|
|
21
|
+
const spki = nodeCrypto.createPublicKey(priv).export({ type: 'spki', format: 'der' }) as Buffer;
|
|
22
|
+
return { publicKey: new Uint8Array(spki.subarray(spki.length - 32)), secretKey };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function x25519(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array {
|
|
26
|
+
const privateKey = nodeCrypto.createPrivateKey({ key: Buffer.concat([PKCS8_X25519, secretKey]), format: 'der', type: 'pkcs8' });
|
|
27
|
+
const pub = nodeCrypto.createPublicKey({ key: Buffer.concat([SPKI_X25519, publicKey]), format: 'der', type: 'spki' });
|
|
28
|
+
return new Uint8Array(nodeCrypto.diffieHellman({ privateKey, publicKey: pub }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Salsa20 (https://cr.yp.to/snuffle/spec.pdf) ─────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const rotl = (v: number, c: number): number => ((v << c) | (v >>> (32 - c))) >>> 0;
|
|
34
|
+
const u32 = (b: Uint8Array, i: number): number => (b[i]! | (b[i + 1]! << 8) | (b[i + 2]! << 16) | (b[i + 3]! << 24)) >>> 0;
|
|
35
|
+
const SIGMA = new TextEncoder().encode('expand 32-byte k');
|
|
36
|
+
|
|
37
|
+
/** The Salsa20 state for a key, a 16-byte input (nonce and counter, or HSalsa20's nonce) and the constants. */
|
|
38
|
+
function state(key: Uint8Array, input: Uint8Array): Uint32Array {
|
|
39
|
+
const s = new Uint32Array(16);
|
|
40
|
+
s[0] = u32(SIGMA, 0); s[5] = u32(SIGMA, 4); s[10] = u32(SIGMA, 8); s[15] = u32(SIGMA, 12);
|
|
41
|
+
for (let i = 0; i < 4; i++) { s[1 + i] = u32(key, i * 4); s[11 + i] = u32(key, 16 + i * 4); }
|
|
42
|
+
for (let i = 0; i < 4; i++) s[6 + i] = u32(input, i * 4);
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
/** Twenty rounds (ten double rounds) over a state, in place. */
|
|
46
|
+
function rounds(x: Uint32Array): void {
|
|
47
|
+
for (let i = 0; i < 10; i++) {
|
|
48
|
+
x[4] ^= rotl((x[0]! + x[12]!) >>> 0, 7); x[8] ^= rotl((x[4]! + x[0]!) >>> 0, 9); x[12] ^= rotl((x[8]! + x[4]!) >>> 0, 13); x[0] ^= rotl((x[12]! + x[8]!) >>> 0, 18);
|
|
49
|
+
x[9] ^= rotl((x[5]! + x[1]!) >>> 0, 7); x[13] ^= rotl((x[9]! + x[5]!) >>> 0, 9); x[1] ^= rotl((x[13]! + x[9]!) >>> 0, 13); x[5] ^= rotl((x[1]! + x[13]!) >>> 0, 18);
|
|
50
|
+
x[14] ^= rotl((x[10]! + x[6]!) >>> 0, 7); x[2] ^= rotl((x[14]! + x[10]!) >>> 0, 9); x[6] ^= rotl((x[2]! + x[14]!) >>> 0, 13); x[10] ^= rotl((x[6]! + x[2]!) >>> 0, 18);
|
|
51
|
+
x[3] ^= rotl((x[15]! + x[11]!) >>> 0, 7); x[7] ^= rotl((x[3]! + x[15]!) >>> 0, 9); x[11] ^= rotl((x[7]! + x[3]!) >>> 0, 13); x[15] ^= rotl((x[11]! + x[7]!) >>> 0, 18);
|
|
52
|
+
x[1] ^= rotl((x[0]! + x[3]!) >>> 0, 7); x[2] ^= rotl((x[1]! + x[0]!) >>> 0, 9); x[3] ^= rotl((x[2]! + x[1]!) >>> 0, 13); x[0] ^= rotl((x[3]! + x[2]!) >>> 0, 18);
|
|
53
|
+
x[6] ^= rotl((x[5]! + x[4]!) >>> 0, 7); x[7] ^= rotl((x[6]! + x[5]!) >>> 0, 9); x[4] ^= rotl((x[7]! + x[6]!) >>> 0, 13); x[5] ^= rotl((x[4]! + x[7]!) >>> 0, 18);
|
|
54
|
+
x[11] ^= rotl((x[10]! + x[9]!) >>> 0, 7); x[8] ^= rotl((x[11]! + x[10]!) >>> 0, 9); x[9] ^= rotl((x[8]! + x[11]!) >>> 0, 13); x[10] ^= rotl((x[9]! + x[8]!) >>> 0, 18);
|
|
55
|
+
x[12] ^= rotl((x[15]! + x[14]!) >>> 0, 7); x[13] ^= rotl((x[12]! + x[15]!) >>> 0, 9); x[14] ^= rotl((x[13]! + x[12]!) >>> 0, 13); x[15] ^= rotl((x[14]! + x[13]!) >>> 0, 18);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const put32 = (out: Uint8Array, at: number, v: number): void => { out[at] = v & 255; out[at + 1] = (v >>> 8) & 255; out[at + 2] = (v >>> 16) & 255; out[at + 3] = (v >>> 24) & 255; };
|
|
59
|
+
|
|
60
|
+
/** HSalsa20: a 32-byte subkey from a key and a 16-byte nonce (the rounds' words 0, 5, 10, 15, 6, 7, 8, 9, not added). */
|
|
61
|
+
function hsalsa20(key: Uint8Array, nonce16: Uint8Array): Uint8Array {
|
|
62
|
+
const x = state(key, nonce16);
|
|
63
|
+
rounds(x);
|
|
64
|
+
const out = new Uint8Array(32);
|
|
65
|
+
[0, 5, 10, 15, 6, 7, 8, 9].forEach((w, i) => put32(out, i * 4, x[w]!));
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The XSalsa20 keystream of `length` bytes for a key and a 24-byte nonce. */
|
|
70
|
+
function xsalsa20Stream(key: Uint8Array, nonce24: Uint8Array, length: number): Uint8Array {
|
|
71
|
+
const sub = hsalsa20(key, nonce24.subarray(0, 16));
|
|
72
|
+
const out = new Uint8Array(length);
|
|
73
|
+
const input = new Uint8Array(16);
|
|
74
|
+
input.set(nonce24.subarray(16, 24), 0);
|
|
75
|
+
for (let block = 0, at = 0; at < length; block++, at += 64) {
|
|
76
|
+
put32(input, 8, block >>> 0);
|
|
77
|
+
put32(input, 12, Math.floor(block / 0x100000000) >>> 0);
|
|
78
|
+
const s = state(sub, input);
|
|
79
|
+
const x = s.slice();
|
|
80
|
+
rounds(x);
|
|
81
|
+
const bytes = new Uint8Array(64);
|
|
82
|
+
for (let i = 0; i < 16; i++) put32(bytes, i * 4, (x[i]! + s[i]!) >>> 0);
|
|
83
|
+
out.set(bytes.subarray(0, Math.min(64, length - at)), at);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Poly1305 (RFC 8439 section 2.5) ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function poly1305(key: Uint8Array, message: Uint8Array): Uint8Array {
|
|
91
|
+
const le = (b: Uint8Array): bigint => b.reduceRight((acc, v) => (acc << 8n) | BigInt(v), 0n);
|
|
92
|
+
const r = le(key.subarray(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn;
|
|
93
|
+
const s = le(key.subarray(16, 32));
|
|
94
|
+
const p = (1n << 130n) - 5n;
|
|
95
|
+
let acc = 0n;
|
|
96
|
+
for (let i = 0; i < message.length; i += 16) {
|
|
97
|
+
const chunk = message.subarray(i, i + 16);
|
|
98
|
+
const n = le(chunk) + (1n << BigInt(8 * chunk.length));
|
|
99
|
+
acc = ((acc + n) * r) % p;
|
|
100
|
+
}
|
|
101
|
+
acc = (acc + s) & ((1n << 128n) - 1n);
|
|
102
|
+
const out = new Uint8Array(16);
|
|
103
|
+
for (let i = 0; i < 16; i++) { out[i] = Number(acc & 255n); acc >>= 8n; }
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── BLAKE2b (RFC 7693), for the sealed box's 24-byte nonce ────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const IV = [0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n];
|
|
110
|
+
const SIGMA_B = [
|
|
111
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
|
112
|
+
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
|
113
|
+
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
|
114
|
+
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
|
115
|
+
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
|
|
116
|
+
];
|
|
117
|
+
const M64 = (1n << 64n) - 1n;
|
|
118
|
+
const rotr64 = (v: bigint, c: bigint): bigint => ((v >> c) | (v << (64n - c))) & M64;
|
|
119
|
+
|
|
120
|
+
function blake2b(input: Uint8Array, outlen: number): Uint8Array {
|
|
121
|
+
const h = IV.slice();
|
|
122
|
+
h[0] ^= 0x01010000n ^ BigInt(outlen);
|
|
123
|
+
const blocks = Math.max(1, Math.ceil(input.length / 128));
|
|
124
|
+
for (let b = 0; b < blocks; b++) {
|
|
125
|
+
const chunk = new Uint8Array(128);
|
|
126
|
+
chunk.set(input.subarray(b * 128, b * 128 + 128));
|
|
127
|
+
const m: bigint[] = [];
|
|
128
|
+
for (let i = 0; i < 16; i++) { let w = 0n; for (let j = 7; j >= 0; j--) w = (w << 8n) | BigInt(chunk[i * 8 + j]!); m.push(w); }
|
|
129
|
+
const last = b === blocks - 1;
|
|
130
|
+
const t = BigInt(last ? input.length : (b + 1) * 128);
|
|
131
|
+
const v = [...h, ...IV];
|
|
132
|
+
v[12] ^= t & M64;
|
|
133
|
+
v[13] ^= t >> 64n;
|
|
134
|
+
if (last) v[14] ^= M64;
|
|
135
|
+
const g = (a: number, bb: number, c: number, d: number, x: bigint, y: bigint): void => {
|
|
136
|
+
v[a] = (v[a]! + v[bb]! + x) & M64; v[d] = rotr64(v[d]! ^ v[a]!, 32n);
|
|
137
|
+
v[c] = (v[c]! + v[d]!) & M64; v[bb] = rotr64(v[bb]! ^ v[c]!, 24n);
|
|
138
|
+
v[a] = (v[a]! + v[bb]! + y) & M64; v[d] = rotr64(v[d]! ^ v[a]!, 16n);
|
|
139
|
+
v[c] = (v[c]! + v[d]!) & M64; v[bb] = rotr64(v[bb]! ^ v[c]!, 63n);
|
|
140
|
+
};
|
|
141
|
+
for (let r = 0; r < 12; r++) {
|
|
142
|
+
const s = SIGMA_B[r % 10]!;
|
|
143
|
+
g(0, 4, 8, 12, m[s[0]!]!, m[s[1]!]!); g(1, 5, 9, 13, m[s[2]!]!, m[s[3]!]!); g(2, 6, 10, 14, m[s[4]!]!, m[s[5]!]!); g(3, 7, 11, 15, m[s[6]!]!, m[s[7]!]!);
|
|
144
|
+
g(0, 5, 10, 15, m[s[8]!]!, m[s[9]!]!); g(1, 6, 11, 12, m[s[10]!]!, m[s[11]!]!); g(2, 7, 8, 13, m[s[12]!]!, m[s[13]!]!); g(3, 4, 9, 14, m[s[14]!]!, m[s[15]!]!);
|
|
145
|
+
}
|
|
146
|
+
for (let i = 0; i < 8; i++) h[i] = h[i]! ^ v[i]! ^ v[i + 8]!;
|
|
147
|
+
}
|
|
148
|
+
const out = new Uint8Array(outlen);
|
|
149
|
+
for (let i = 0; i < outlen; i++) out[i] = Number((h[i >> 3]! >> BigInt(8 * (i & 7))) & 255n);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A sealed box opened with the recipient's key pair: the plaintext, or undefined when it is not one for this key (its
|
|
154
|
+
* MAC does not hold). */
|
|
155
|
+
export function sealedBoxOpen(sealed: Uint8Array, keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }): Uint8Array | undefined {
|
|
156
|
+
if (sealed.length < 48) return undefined;
|
|
157
|
+
const epk = sealed.subarray(0, 32);
|
|
158
|
+
const mac = sealed.subarray(32, 48);
|
|
159
|
+
const body = sealed.subarray(48);
|
|
160
|
+
const nonce = blake2b(new Uint8Array([...epk, ...keyPair.publicKey]), 24);
|
|
161
|
+
const key = hsalsa20(x25519(keyPair.secretKey, epk), new Uint8Array(16));
|
|
162
|
+
const stream = xsalsa20Stream(key, nonce, 32 + body.length);
|
|
163
|
+
const expected = poly1305(stream.subarray(0, 32), body);
|
|
164
|
+
let diff = 0;
|
|
165
|
+
for (let i = 0; i < 16; i++) diff |= expected[i]! ^ mac[i]!;
|
|
166
|
+
if (diff !== 0) return undefined;
|
|
167
|
+
const out = new Uint8Array(body.length);
|
|
168
|
+
for (let i = 0; i < body.length; i++) out[i] = body[i]! ^ stream[32 + i]!;
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A sealed box for a recipient's public key, as a client makes one (`gh secret set`, libsodium's `crypto_box_seal`): a
|
|
173
|
+
* fresh ephemeral key pair from `seed` (random when none is given), then `ephemeral_pk || mac || ciphertext`. */
|
|
174
|
+
export function sealedBoxSeal(message: Uint8Array, recipientPublicKey: Uint8Array, seed?: string): Uint8Array {
|
|
175
|
+
const ephemeral = sealedBoxKeyPair(seed ?? nodeCrypto.randomBytes(32).toString('hex'));
|
|
176
|
+
const nonce = blake2b(new Uint8Array([...ephemeral.publicKey, ...recipientPublicKey]), 24);
|
|
177
|
+
const key = hsalsa20(x25519(ephemeral.secretKey, recipientPublicKey), new Uint8Array(16));
|
|
178
|
+
const stream = xsalsa20Stream(key, nonce, 32 + message.length);
|
|
179
|
+
const body = new Uint8Array(message.length);
|
|
180
|
+
for (let i = 0; i < message.length; i++) body[i] = message[i]! ^ stream[32 + i]!;
|
|
181
|
+
return new Uint8Array([...ephemeral.publicKey, ...poly1305(stream.subarray(0, 32), body), ...body]);
|
|
182
|
+
}
|
package/src/serve-http.ts
CHANGED
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
// asynchronous on Node — Bun's synchronous bind is the special case.
|
|
6
6
|
//
|
|
7
7
|
// Nothing here runs at module scope, and `node:http` is reached through `process.getBuiltinModule`
|
|
8
|
-
// rather than a static import: this module rides into
|
|
8
|
+
// rather than a static import: this module rides into browser client bundles through the kernel's
|
|
9
9
|
// entrypoint, where a static `node:` import is the 2026-09-06 class of break.
|
|
10
10
|
|
|
11
|
+
import type { TwinStream } from './twin-fetch.ts';
|
|
12
|
+
|
|
11
13
|
export type HttpHandler = (request: Request) => Response | Promise<Response>;
|
|
12
14
|
export type WebSocketPeer = { send: (data: string | Uint8Array) => unknown; close: (code?: number, reason?: string) => unknown };
|
|
13
15
|
export type WebSocketUpgrade = {
|
|
14
16
|
accepts: (request: Request) => boolean;
|
|
17
|
+
/** The subprotocol the server selects for this request, answered in `Sec-WebSocket-Protocol`; none when undefined. */
|
|
18
|
+
protocol?: (request: Request) => string | undefined;
|
|
15
19
|
open: (peer: WebSocketPeer, request: Request) => void;
|
|
16
20
|
message: (peer: WebSocketPeer, data: string | Uint8Array) => void | Promise<void>;
|
|
17
21
|
close: (peer: WebSocketPeer) => void;
|
|
@@ -56,6 +60,10 @@ export const WORLD_BOOT_PATH = '/__volter/world-boot';
|
|
|
56
60
|
export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer> {
|
|
57
61
|
// Inside a World every server made through the seam answers which boot it belongs to, so the World can tell its own
|
|
58
62
|
// twin from a leftover World's that holds the same port. Outside a World nothing changes.
|
|
63
|
+
// a pack's fetch carries the upgrade its sockets take (pack-fetch.ts), read before anything wraps the fetch: served on
|
|
64
|
+
// its own or in a World, its sockets answer
|
|
65
|
+
const carried = options.upgrade ?? (options.fetch as { upgrade?: WebSocketUpgrade }).upgrade;
|
|
66
|
+
if (carried) options = { ...options, upgrade: carried };
|
|
59
67
|
const worldBoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VOLTER_WORLD_BOOT_ID;
|
|
60
68
|
if (worldBoot) {
|
|
61
69
|
const served = options.fetch;
|
|
@@ -72,6 +80,8 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
72
80
|
const server = bun.serve({ ...options, hostname, port: options.port ?? 0,
|
|
73
81
|
...(upgrade ? {
|
|
74
82
|
fetch: (request: Request, server: { upgrade: (request: Request, options: { data: { request: Request } }) => boolean }) => {
|
|
83
|
+
// Bun answers the first subprotocol the client offers by itself; a header set here would be a second one, which a
|
|
84
|
+
// client refuses (measured, Bun 1.2.20: Node's client reads `realtime`; with the header added, a duplicate)
|
|
75
85
|
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket' && upgrade.accepts(request) && server.upgrade(request, { data: { request } })) return undefined;
|
|
76
86
|
return options.fetch(request);
|
|
77
87
|
},
|
|
@@ -94,6 +104,22 @@ type NodeStream = typeof import('node:stream');
|
|
|
94
104
|
* module that also rides into a browser bundle reaches `node:crypto` with. A bare `require()`
|
|
95
105
|
* is Bun's alone; Node's ESM has none (found 2026-09-07 by the pages running under node). */
|
|
96
106
|
export function nodeBuiltin<T = unknown>(name: string): T { return builtin<T>(name); }
|
|
107
|
+
|
|
108
|
+
/** A raw-TCP twin's listener (the seam's byte-stream half): its `create<Name>TwinStream` served on a TCP port, each
|
|
109
|
+
* socket one connection, on loopback unless the caller names a hostname. Runtime-neutral, as `serveHttp` is: Node's
|
|
110
|
+
* `net` reached at call time, never imported. */
|
|
111
|
+
export async function serveStream(options: { port?: number; hostname?: string; stream: TwinStream }): Promise<{ port: number; stop: () => void }> {
|
|
112
|
+
const net = builtin<typeof import('node:net')>('node:net');
|
|
113
|
+
const server = net.createServer((socket) => {
|
|
114
|
+
const conn = options.stream({ write: (bytes) => { socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '127.0.0.1');
|
|
115
|
+
socket.on('data', (chunk: Buffer) => conn.data(new Uint8Array(chunk)));
|
|
116
|
+
socket.on('close', () => conn.close());
|
|
117
|
+
socket.on('error', () => conn.close());
|
|
118
|
+
});
|
|
119
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, options.hostname ?? '127.0.0.1', () => resolve()); });
|
|
120
|
+
const address = server.address();
|
|
121
|
+
return { port: typeof address === 'object' && address ? address.port : options.port ?? 0, stop: () => { server.close(); } };
|
|
122
|
+
}
|
|
97
123
|
function builtin<T>(name: string): T {
|
|
98
124
|
const get = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
|
|
99
125
|
if (typeof get !== 'function') throw new Error(`serveHttp: this runtime is neither Bun nor a Node with process.getBuiltinModule (Node 22.3+)`);
|
|
@@ -276,10 +302,12 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
276
302
|
let socketServer: import('ws').WebSocketServer | undefined;
|
|
277
303
|
if (options.upgrade) {
|
|
278
304
|
const { WebSocketServer } = await import('ws');
|
|
279
|
-
socketServer = new WebSocketServer({ noServer: true });
|
|
280
305
|
const upgrade = options.upgrade;
|
|
306
|
+
const requestOf = (req: import('node:http').IncomingMessage): Request => new Request(`${protocol}://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`, { headers: Object.fromEntries(Object.entries(req.headers).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) });
|
|
307
|
+
// the subprotocol the socket selects, answered as Bun's branch answers it
|
|
308
|
+
socketServer = new WebSocketServer({ noServer: true, handleProtocols: (_offered, req) => upgrade.protocol?.(requestOf(req)) ?? false });
|
|
281
309
|
server.on('upgrade', (req, socket, head) => {
|
|
282
|
-
const request =
|
|
310
|
+
const request = requestOf(req);
|
|
283
311
|
if (!upgrade.accepts(request)) { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; }
|
|
284
312
|
socketServer!.handleUpgrade(req, socket, head, (peer) => {
|
|
285
313
|
peer.on('message', (data, binary) => { const bytes = Array.isArray(data) ? Buffer.concat(data) : data instanceof ArrayBuffer ? new Uint8Array(data) : data; Promise.resolve(upgrade.message(peer, binary ? new Uint8Array(bytes) : Buffer.from(bytes).toString())).catch(() => peer.close(1011, 'handler failed')); });
|
package/src/serve.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, pr
|
|
|
20
20
|
import type { ActionProjection, TwinAction, TwinActionPrecondition } from './actions.ts';
|
|
21
21
|
import { observeWorldPaths, worldPaths } from './storage.ts';
|
|
22
22
|
import { getActiveWorldStore } from './world-store.ts';
|
|
23
|
+
import { getActiveBlobStore } from './blob-store.ts';
|
|
23
24
|
import { parseTraceparent } from './trace-context.ts';
|
|
24
25
|
|
|
25
26
|
// ── the request journal: the INSPECT half of the read path ─────────────────────────────────────
|
|
@@ -80,6 +81,10 @@ export type TwinCredentialShape = {
|
|
|
80
81
|
* `token:read`). The World replaces any a caller sent; the serve seam records it in the twin's request journal and
|
|
81
82
|
* removes it before the twin's handler. A twin reached on its own port, not through a World, records what it was sent. */
|
|
82
83
|
export const CALLER_HEADER = 'x-volter-caller';
|
|
84
|
+
/** The caller as it travels in CALLER_HEADER: percent-encoded, since a key's name is a person's words (`preview pr-8 ·
|
|
85
|
+
* org token ci`) and a header carries bytes only. `callerFromHeader` reads it back; a value it cannot decode stands. */
|
|
86
|
+
export const callerForHeader = (caller: string): string => encodeURIComponent(caller);
|
|
87
|
+
export const callerFromHeader = (value: string): string => { try { return decodeURIComponent(value); } catch { return value; } };
|
|
83
88
|
|
|
84
89
|
export type TwinRequestJournalEntry = {
|
|
85
90
|
at?: string;
|
|
@@ -297,9 +302,9 @@ export function journalTwinRequest(service: string, entry: TwinRequestJournalEnt
|
|
|
297
302
|
// factory's closure. They are recoverable without touching a single pack, because handling a
|
|
298
303
|
// request makes the pack call the kernel, and every kernel state path funnels through
|
|
299
304
|
// `worldPaths(service, root)` (storage.ts). Observing the FIRST such call a server makes is the
|
|
300
|
-
// twin naming itself: `
|
|
305
|
+
// twin naming itself: `deepgram` under `/tmp/world/data/deepgram`. It is cached per server, so only
|
|
301
306
|
// the first request pays for it. A server whose first request touches no state falls back to the
|
|
302
|
-
// call site (
|
|
307
|
+
// call site (a catalog checkout or `@volter/twin-<vendor>`) and the process's `--root`
|
|
303
308
|
// argument, which is how the world runtime launches every twin.
|
|
304
309
|
|
|
305
310
|
export const TWIN_JOURNAL_IDENTITY = Symbol.for('volter.twin.requestJournal.identity');
|
|
@@ -310,12 +315,9 @@ export type TwinJournalIdentity = { service: string; root?: string };
|
|
|
310
315
|
/** One capture slot per in-flight request, so two twins co-located in ONE process (the shared
|
|
311
316
|
* host) can never read each other's identity off a racing sibling's first request.
|
|
312
317
|
*
|
|
313
|
-
* LAZY ON PURPOSE
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* so it survives tree-shaking and lands in the client — where `node:async_hooks` does not
|
|
317
|
-
* resolve, `new` throws at bundle evaluation, and the React app never mounts. That took out
|
|
318
|
-
* every mirror UI in the estate at once. Nothing in this module may run at module scope. */
|
|
318
|
+
* LAZY ON PURPOSE: kernel modules ride into browser bundles (kernel imports are free of side effects), and a module-scope
|
|
319
|
+
* `new AsyncLocalStorage()` survives tree-shaking into a client, where `node:async_hooks` does not resolve and `new`
|
|
320
|
+
* throws at bundle evaluation. Nothing in this module may run at module scope. */
|
|
319
321
|
let identityCapture: AsyncLocalStorage<{ identity?: TwinJournalIdentity }> | undefined;
|
|
320
322
|
|
|
321
323
|
function identitySlot(): AsyncLocalStorage<{ identity?: TwinJournalIdentity }> {
|
|
@@ -344,7 +346,7 @@ const INFRA_PACKAGES = new Set(['world-runtime', 'world-tooling', 'world-attach'
|
|
|
344
346
|
|
|
345
347
|
export function vendorFromStack(stack: string | undefined): string | undefined {
|
|
346
348
|
if (!stack) return undefined;
|
|
347
|
-
const pattern = /(?:
|
|
349
|
+
const pattern = /(?:twin-packs-p[23][\\/]|@volter[\\/]twin-)([A-Za-z0-9_-]+)/g;
|
|
348
350
|
for (const match of stack.matchAll(pattern)) {
|
|
349
351
|
const name = match[1]!;
|
|
350
352
|
if (!INFRA_PACKAGES.has(name)) return name;
|
|
@@ -365,7 +367,7 @@ export function vendorFromStack(stack: string | undefined): string | undefined {
|
|
|
365
367
|
* service is an argument rather than a fact about the pack.
|
|
366
368
|
*/
|
|
367
369
|
export function installTwinRequestJournal(): boolean {
|
|
368
|
-
// a browser bundle
|
|
370
|
+
// a browser bundle carrying the kernel: nothing to wrap, nothing to touch
|
|
369
371
|
if (typeof window !== 'undefined' || typeof document !== 'undefined') return false;
|
|
370
372
|
const bun = (globalThis as { Bun?: { serve?: unknown } }).Bun;
|
|
371
373
|
const original = bun && typeof bun.serve === 'function' ? bun.serve as (...args: unknown[]) => { port?: number } : undefined;
|
|
@@ -402,7 +404,7 @@ function journalingFetch(config: Record<string | symbol, unknown> & { fetch: (..
|
|
|
402
404
|
const inner = config.fetch as (this: unknown, request: Request, server: unknown) => unknown;
|
|
403
405
|
const journaling = async function (this: unknown, request: Request, server: unknown) {
|
|
404
406
|
// who made it is read here and goes no further: no twin's handler (a tunnel's, a proxy's) hands it on
|
|
405
|
-
const
|
|
407
|
+
const sent = request.headers.get(CALLER_HEADER); const caller = sent === null ? undefined : callerFromHeader(sent);
|
|
406
408
|
if (caller !== undefined) { const headers = new Headers(request.headers); headers.delete(CALLER_HEADER); request = new Request(request, { headers }); }
|
|
407
409
|
// The World's own boot probe (serve-http.ts, WORLD_BOOT_PATH) is not the app's traffic: never journaled.
|
|
408
410
|
if (!twinRequestJournalEnabled() || new URL(request.url).pathname === WORLD_BOOT_PATH) return inner.call(this, request, server);
|
|
@@ -506,6 +508,48 @@ export type AtomicTwinWriteDecision<T> =
|
|
|
506
508
|
| { kind: 'skip'; value: T }
|
|
507
509
|
| { kind: 'write'; value: T; write: TwinWriteInput };
|
|
508
510
|
|
|
511
|
+
/** The largest string a write's recorded request keeps in the log itself. */
|
|
512
|
+
const INPUT_INLINE_MAX = 64 * 1024;
|
|
513
|
+
/**
|
|
514
|
+
* A write's recorded request, with each string longer than INPUT_INLINE_MAX (an attached file's base64: an npm
|
|
515
|
+
* publish's tarball) kept once as a content-addressed blob under the service's state and named in its place as
|
|
516
|
+
* `{ $blob, bytes }`. The request stays whole and deterministic, and a body several writes record is kept once, never
|
|
517
|
+
* copied into the log (docs/contributing/architecture.md, "The log keeps a large request once"). `recordedInput` reads
|
|
518
|
+
* it back whole.
|
|
519
|
+
*/
|
|
520
|
+
async function offloadInput(service: string, input: unknown, root: string | undefined): Promise<unknown> {
|
|
521
|
+
if (typeof input === 'string') {
|
|
522
|
+
if (input.length <= INPUT_INLINE_MAX) return input;
|
|
523
|
+
// hashed as it is (UTF-8), and encoded only when its blob is new: each write of a request records the same body
|
|
524
|
+
const key = join(worldPaths(service, root).dir, 'inputs', createHash('sha256').update(input, 'utf8').digest('hex'));
|
|
525
|
+
const blobs = getActiveBlobStore();
|
|
526
|
+
if (!(await blobs.exists(key))) await blobs.put(key, Buffer.from(input, 'utf8'));
|
|
527
|
+
return { $blob: key, bytes: Buffer.byteLength(input, 'utf8') };
|
|
528
|
+
}
|
|
529
|
+
if (Array.isArray(input)) return Promise.all(input.map((v) => offloadInput(service, v, root)));
|
|
530
|
+
if (input && typeof input === 'object') {
|
|
531
|
+
const out: Record<string, unknown> = {};
|
|
532
|
+
for (const [k, v] of Object.entries(input)) out[k] = await offloadInput(service, v, root);
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
return input;
|
|
536
|
+
}
|
|
537
|
+
/** A recorded request as it was sent: each string the log keeps as a blob, read back. */
|
|
538
|
+
export async function recordedInput(input: unknown): Promise<unknown> {
|
|
539
|
+
if (Array.isArray(input)) return Promise.all(input.map(recordedInput));
|
|
540
|
+
if (input && typeof input === 'object') {
|
|
541
|
+
const ref = input as { $blob?: unknown; bytes?: unknown };
|
|
542
|
+
if (typeof ref.$blob === 'string' && typeof ref.bytes === 'number' && Object.keys(input).length === 2) {
|
|
543
|
+
const bytes = await getActiveBlobStore().get(ref.$blob);
|
|
544
|
+
return bytes ? new TextDecoder().decode(bytes) : undefined;
|
|
545
|
+
}
|
|
546
|
+
const out: Record<string, unknown> = {};
|
|
547
|
+
for (const [k, v] of Object.entries(input)) out[k] = await recordedInput(v);
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
return input;
|
|
551
|
+
}
|
|
552
|
+
|
|
509
553
|
function actionForTwinWrite(service: string, write: TwinWriteInput): TwinAction {
|
|
510
554
|
const occurredAt = write.occurredAt ?? new Date().toISOString();
|
|
511
555
|
// Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
|
|
@@ -609,7 +653,7 @@ export async function applyTwinWrite(
|
|
|
609
653
|
write: TwinWriteInput,
|
|
610
654
|
root?: string,
|
|
611
655
|
): Promise<{ result: TwinWriteResult; resource: TwinResource }> {
|
|
612
|
-
const action = actionForTwinWrite(service, write);
|
|
656
|
+
const action = actionForTwinWrite(service, write.input === undefined ? write : { ...write, input: await offloadInput(service, write.input, root) as TwinWriteInput['input'] });
|
|
613
657
|
// A LOCAL VENDOR WRITE IS AN OCCURRENCE. Two calls are two actions, even byte-identical in the
|
|
614
658
|
// same instant, and the ordinal in the action id says which occurrence this is. The kernel used
|
|
615
659
|
// to derive identity from (content + occurredAt millisecond) and drop a repeat as `replayed`,
|
|
@@ -617,8 +661,8 @@ export async function applyTwinWrite(
|
|
|
617
661
|
// under a pinned world clock, where every write in a world shares one instant. That default cost
|
|
618
662
|
// github, slack and jira live defects while the recipe told each pack to defend itself with a
|
|
619
663
|
// per-write ordinal and a fifth of the catalog opted out via `uniqueness`. A default every pack
|
|
620
|
-
// must remember to defend against is not a default. See docs/contributing/
|
|
621
|
-
//
|
|
664
|
+
// must remember to defend against is not a default. See docs/contributing/architecture.md,
|
|
665
|
+
// "The state kernel", "A local write is an occurrence".
|
|
622
666
|
//
|
|
623
667
|
// AT-MOST-ONCE is opt in, by KEY: `idempotencyKey` makes the id a function of the caller's own
|
|
624
668
|
// request identity, so a re-issue collapses onto the first and answers `replayed`. Content can
|