@volter/world-core 2.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/LICENSE +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
const MAX_ORIGIN_LENGTH = 2048;
|
|
2
|
+
function isLoopbackHost(hostname) {
|
|
3
|
+
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]' || hostname === '::1';
|
|
4
|
+
}
|
|
5
|
+
/** Throws with the precise refusal; returns the parsed URL when the origin is usable. */
|
|
6
|
+
export function validateRemoteOrigin(origin, opts = {}) {
|
|
7
|
+
if (origin.length > MAX_ORIGIN_LENGTH)
|
|
8
|
+
throw new Error(`remote origin exceeds ${MAX_ORIGIN_LENGTH} characters`);
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(origin);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error('remote origin must be an absolute URL');
|
|
15
|
+
}
|
|
16
|
+
if (url.username !== '' || url.password !== '')
|
|
17
|
+
throw new Error('remote origin must not carry userinfo');
|
|
18
|
+
const loopback = isLoopbackHost(url.hostname);
|
|
19
|
+
if (url.protocol !== 'https:') {
|
|
20
|
+
if (!(url.protocol === 'http:' && loopback && opts.allowInsecureLoopback === true)) {
|
|
21
|
+
throw new Error('remote origin must be https (http is allowed only to loopback, and only where the shell permits it)');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!loopback || opts.allowInsecureLoopback !== true) {
|
|
25
|
+
if (loopback)
|
|
26
|
+
throw new Error('remote origin must not target loopback on this deployment');
|
|
27
|
+
// Trailing dots defeat suffix/equality checks ('localhost.' resolves as localhost —
|
|
28
|
+
// review B2); strip before every hostname comparison.
|
|
29
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.+$/, '');
|
|
30
|
+
// NO IP-LITERAL ORIGINS, v4 or v6 (review B2): the WHATWG parser normalizes exotic
|
|
31
|
+
// v4 spellings to dotted form, but v6 carries mapped-v4 ([::ffff:7f00:1]), NAT64
|
|
32
|
+
// and [::] forms no regex allowlist survives — and no real vendor publishes an IP
|
|
33
|
+
// origin. Hostnames only; loopback literals ride the explicit allowance above.
|
|
34
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || url.hostname.startsWith('[') || host.includes(':')) {
|
|
35
|
+
throw new Error('remote origin must be a hostname, never an IP literal');
|
|
36
|
+
}
|
|
37
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal')) {
|
|
38
|
+
throw new Error('remote origin must not target a local-only hostname');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return url;
|
|
42
|
+
}
|
|
43
|
+
/** Build the kernel's RemoteExecute (R14 scheduled pull) from the link's validated
|
|
44
|
+
* origin + the sealed credential — a pack never holds a credential, a scheduler never
|
|
45
|
+
* learns a vendor shape. */
|
|
46
|
+
const EGRESS_TIMEOUT_MS = 30_000;
|
|
47
|
+
/** HMAC-SHA256 over WebCrypto — NOT `node:crypto`, deliberately. This file is on the credential
|
|
48
|
+
* path and the host it runs on may be workerd (the contract's Cloudflare shell), which is the same
|
|
49
|
+
* reason credential.ts is WebCrypto-only. Kept local rather than exported: nothing outside the
|
|
50
|
+
* executor should be signing with a credential's secret. */
|
|
51
|
+
async function hmacSha256(secret, payload, encoding) {
|
|
52
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
53
|
+
const bytes = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)));
|
|
54
|
+
if (encoding === 'hex')
|
|
55
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
56
|
+
let binary = '';
|
|
57
|
+
for (const b of bytes)
|
|
58
|
+
binary += String.fromCharCode(b);
|
|
59
|
+
return btoa(binary);
|
|
60
|
+
}
|
|
61
|
+
const encoder = new TextEncoder();
|
|
62
|
+
async function hmacRaw(key, data) {
|
|
63
|
+
const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
64
|
+
return new Uint8Array(await crypto.subtle.sign('HMAC', k, encoder.encode(data)));
|
|
65
|
+
}
|
|
66
|
+
const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
67
|
+
async function sha256Hex(data) {
|
|
68
|
+
return hex(new Uint8Array(await crypto.subtle.digest('SHA-256', typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data))));
|
|
69
|
+
}
|
|
70
|
+
/** RFC3986 encoding, which is stricter than `encodeURIComponent` about these four. */
|
|
71
|
+
// The apostrophe is written \x27 ON PURPOSE. A raw one inside a regex character class desynchronizes
|
|
72
|
+
// the A2 guardrail's string-stripper — it reads the rest of the file as one long string literal and
|
|
73
|
+
// then finds a bare `x` in `x-amz-date`, which it correctly refuses as the kernel naming a vendor
|
|
74
|
+
// (there is a pack called `x`). The guardrail is blunt by design; this is the cheaper side to bend.
|
|
75
|
+
const rfc3986 = (v) => encodeURIComponent(v).replace(/[!\x27()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
76
|
+
/**
|
|
77
|
+
* AWS SIGNATURE VERSION 4, the published algorithm — not a vendor branch. Everything specific to a
|
|
78
|
+
* vendor (which region, which service) arrives as an argument from the pack's `scope`.
|
|
79
|
+
*
|
|
80
|
+
* Returns the headers to add. The caller has already applied `credential.headers`, so this signs
|
|
81
|
+
* over what will actually be sent.
|
|
82
|
+
*/
|
|
83
|
+
async function awsSigV4Headers(input) {
|
|
84
|
+
const { method, url, body, keyId, secret, region, service, headers, at } = input;
|
|
85
|
+
const amzDate = `${at.toISOString().slice(0, 19).replace(/[:-]/g, '')}Z`; // YYYYMMDDTHHMMSSZ
|
|
86
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
87
|
+
const payloadHash = await sha256Hex(body ?? '');
|
|
88
|
+
// S3 does NOT double-encode the path and every other service does. That clause is part of the
|
|
89
|
+
// SigV4 specification itself, which is why it lives with the algorithm rather than in a switch
|
|
90
|
+
// table on vendor identity.
|
|
91
|
+
//
|
|
92
|
+
// `url.pathname` is ALREADY RFC3986-encoded by the URL parser, so S3 — which signs the path as
|
|
93
|
+
// sent — takes it unchanged. Every other service signs the path encoded a SECOND time, which is
|
|
94
|
+
// encoding the already-encoded form (a space is `%2520`, not `%20`). Encoding twice for S3, or
|
|
95
|
+
// once for the rest, both produce a signature the vendor rejects.
|
|
96
|
+
const raw = url.pathname === '' ? '/' : url.pathname;
|
|
97
|
+
const canonicalUri = service === 's3' ? raw : raw.split('/').map(rfc3986).join('/');
|
|
98
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
99
|
+
.map(([k, v]) => [rfc3986(k), rfc3986(v)])
|
|
100
|
+
// `left`/`right`, not `x`/`y`: there is a vendor pack named `x`, and the A2 guardrail reads a
|
|
101
|
+
// bare `x` in kernel source as the kernel branching on vendor identity. It is right to be that
|
|
102
|
+
// blunt — the name is free to change and the invariant is not.
|
|
103
|
+
.sort((left, right) => (left[0] === right[0] ? (left[1] < right[1] ? -1 : 1) : left[0] < right[0] ? -1 : 1))
|
|
104
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
105
|
+
.join('&');
|
|
106
|
+
// Sign the host, the date, the payload hash, and anything the vendor's own protocol carries
|
|
107
|
+
// (`x-amz-*`, a content type). Sorted and lower-cased, values trimmed — the canonical form.
|
|
108
|
+
const signable = new Map([['host', url.host], ['x-amz-date', amzDate], ['x-amz-content-sha256', payloadHash]]);
|
|
109
|
+
headers.forEach((value, name) => {
|
|
110
|
+
const n = name.toLowerCase();
|
|
111
|
+
if (n.startsWith('x-amz-') || n === 'content-type')
|
|
112
|
+
signable.set(n, value.trim().replace(/\s+/g, ' '));
|
|
113
|
+
});
|
|
114
|
+
const names = [...signable.keys()].sort();
|
|
115
|
+
const canonicalHeaders = names.map((n) => `${n}:${signable.get(n)}\n`).join('');
|
|
116
|
+
const signedHeaders = names.join(';');
|
|
117
|
+
const canonicalRequest = [method.toUpperCase(), canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join('\n');
|
|
118
|
+
const scope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
119
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, await sha256Hex(canonicalRequest)].join('\n');
|
|
120
|
+
// the four chained HMACs
|
|
121
|
+
let key = encoder.encode(`AWS4${secret}`);
|
|
122
|
+
for (const part of [dateStamp, region, service, 'aws4_request'])
|
|
123
|
+
key = await hmacRaw(key, part);
|
|
124
|
+
const signature = hex(await hmacRaw(key, stringToSign));
|
|
125
|
+
return {
|
|
126
|
+
'x-amz-date': amzDate,
|
|
127
|
+
'x-amz-content-sha256': payloadHash,
|
|
128
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${keyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function buildRemoteExecute(origin, credential, auth, vendorHosts, options = {}) {
|
|
132
|
+
if (auth?.in === 'exchange')
|
|
133
|
+
return exchangeExecute(origin, credential, auth, vendorHosts, options.custody);
|
|
134
|
+
return async ({ method, path, headers, body, responseType, presigned }) => {
|
|
135
|
+
// A PRESIGNED UPLOAD URL the vendor returned: its own signature is its authorization, so the
|
|
136
|
+
// sealed credential never goes with it, whatever host it names. A host other than the root's
|
|
137
|
+
// passes the same validation the root does; the root's own origin (a twin standing in for the
|
|
138
|
+
// vendor on loopback) is already validated.
|
|
139
|
+
if (presigned) {
|
|
140
|
+
let target;
|
|
141
|
+
try {
|
|
142
|
+
target = new URL(path);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new Error('remote execute: a presigned request carries an absolute URL');
|
|
146
|
+
}
|
|
147
|
+
if (target.username || target.password)
|
|
148
|
+
throw new Error('remote execute: a presigned URL carries no userinfo');
|
|
149
|
+
// any other host passes the root's own validator: https, no IP literal, no local-only name
|
|
150
|
+
if (target.origin !== origin.origin)
|
|
151
|
+
validateRemoteOrigin(target.origin);
|
|
152
|
+
const response = await fetch(target, {
|
|
153
|
+
method,
|
|
154
|
+
headers: new Headers(headers ?? {}),
|
|
155
|
+
...(body === undefined ? {} : { body: typeof body === 'string' ? body : new Uint8Array(body) }),
|
|
156
|
+
redirect: 'manual',
|
|
157
|
+
signal: AbortSignal.timeout(EGRESS_TIMEOUT_MS),
|
|
158
|
+
});
|
|
159
|
+
const responseHeaders = {};
|
|
160
|
+
response.headers.forEach((value, key) => { responseHeaders[key] = value; });
|
|
161
|
+
if (responseType === 'bytes') {
|
|
162
|
+
const bodyBytes = new Uint8Array(await response.arrayBuffer());
|
|
163
|
+
return { status: response.status, headers: responseHeaders, body: new TextDecoder().decode(bodyBytes), bodyBytes };
|
|
164
|
+
}
|
|
165
|
+
return { status: response.status, headers: responseHeaders, body: await response.text() };
|
|
166
|
+
}
|
|
167
|
+
// PATH ANCHORING (review M5): '@evil.com/x' concatenated after an origin reparses
|
|
168
|
+
// the origin into USERINFO and ships the credential to evil.com. A path is a path:
|
|
169
|
+
// it starts with '/', and the parse must land on the validated origin, asserted.
|
|
170
|
+
let target;
|
|
171
|
+
if (/^https:\/\//i.test(path) && vendorHosts?.length) {
|
|
172
|
+
// a second host the pack declares for this vendor, on a path its rule names: validated as the root is
|
|
173
|
+
target = new URL(path);
|
|
174
|
+
if (target.username || target.password)
|
|
175
|
+
throw new Error('remote execute: a vendor URL carries no userinfo');
|
|
176
|
+
validateRemoteOrigin(target.origin);
|
|
177
|
+
const declared = vendorHosts.some((r) => 'host' in r && !r.exclude && r.host === target.hostname && (r.pathPattern === undefined || new RegExp(r.pathPattern).test(target.pathname)));
|
|
178
|
+
if (!declared)
|
|
179
|
+
throw new Error(`remote execute: ${target.host}${target.pathname.slice(0, 64)} is not a host this vendor's pack declares for this path`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if (!path.startsWith('/'))
|
|
183
|
+
throw new Error(`remote execute: path must start with '/' (got ${JSON.stringify(path.slice(0, 64))})`);
|
|
184
|
+
// the base keeps its path: a served world's twin lives under /<org>/<world>/<vendor>, github.com under /
|
|
185
|
+
const base = origin.href.replace(/\/+$/, '');
|
|
186
|
+
target = new URL(base + path);
|
|
187
|
+
if (target.origin !== origin.origin || !target.pathname.startsWith(origin.pathname.replace(/\/+$/, '')))
|
|
188
|
+
throw new Error('remote execute: path escaped the validated origin');
|
|
189
|
+
}
|
|
190
|
+
const outbound = new Headers(headers ?? {});
|
|
191
|
+
for (const [name, value] of Object.entries(credential.headers))
|
|
192
|
+
outbound.set(name, value);
|
|
193
|
+
// ── THE STRATEGIES BEYOND HEADER REPLACEMENT ──────────────────────────────────────────────
|
|
194
|
+
// Applied AFTER the anchoring check above, so nothing here can move the request off the
|
|
195
|
+
// validated origin: `searchParams.set` only ever touches the query, and a header is a header.
|
|
196
|
+
//
|
|
197
|
+
// FAIL CLOSED. A strategy with no secret must never send a request that merely LOOKS
|
|
198
|
+
// authenticated — that silence is the whole defect this exists to remove. The refusals name the
|
|
199
|
+
// strategy and the parameter, never the secret.
|
|
200
|
+
if (auth !== undefined) {
|
|
201
|
+
const secret = credential.secret;
|
|
202
|
+
if (typeof secret !== 'string' || secret === '') {
|
|
203
|
+
throw new Error(`remote execute: the ${auth.in} credential strategy needs \`secret\` on the sealed credential, and it is absent`);
|
|
204
|
+
}
|
|
205
|
+
if (auth.in === 'query') {
|
|
206
|
+
// `set`, not `append`: it REPLACES whatever placeholder the pack put in its own path,
|
|
207
|
+
// exactly as `outbound.set` replaces a placeholder authorization header.
|
|
208
|
+
target.searchParams.set(auth.name, secret);
|
|
209
|
+
}
|
|
210
|
+
else if (auth.algorithm === 'aws-sigv4') {
|
|
211
|
+
const keyId = credential.keyId;
|
|
212
|
+
if (typeof keyId !== 'string' || keyId === '') {
|
|
213
|
+
throw new Error('remote execute: the aws-sigv4 credential strategy needs `keyId` on the sealed credential, and it is absent');
|
|
214
|
+
}
|
|
215
|
+
// the HOST goes with it: an AWS endpoint names its own region, and only the pack knows how
|
|
216
|
+
const { region, service } = auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) });
|
|
217
|
+
const signed = await awsSigV4Headers({ method, url: target, ...(body === undefined ? {} : { body }), keyId, secret, region, service, headers: outbound, at: new Date() });
|
|
218
|
+
for (const [name, value] of Object.entries(signed))
|
|
219
|
+
outbound.set(name, value);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// `null` is the vendor's own exemption, declared by the pack — not an error, and not a
|
|
223
|
+
// reason to send an unsigned header.
|
|
224
|
+
if (body instanceof Uint8Array)
|
|
225
|
+
throw new Error('remote execute: this signature canonicalizer accepts text bodies only');
|
|
226
|
+
const payload = auth.canonical({ method, path, ...(body === undefined ? {} : { body }) });
|
|
227
|
+
if (payload !== null)
|
|
228
|
+
outbound.set(auth.header, await hmacSha256(secret, payload, auth.encoding));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const response = await fetch(target, {
|
|
232
|
+
method,
|
|
233
|
+
headers: outbound,
|
|
234
|
+
...(body === undefined ? {} : { body: typeof body === 'string' ? body : new Uint8Array(body) }),
|
|
235
|
+
redirect: 'manual',
|
|
236
|
+
// A hung vendor must not hold the isolate store fence open-endedly (review B2);
|
|
237
|
+
// the pull records the abort as ordinary failure health and backs off.
|
|
238
|
+
signal: AbortSignal.timeout(EGRESS_TIMEOUT_MS),
|
|
239
|
+
});
|
|
240
|
+
const responseHeaders = {};
|
|
241
|
+
response.headers.forEach((value, key) => { responseHeaders[key] = value; });
|
|
242
|
+
if (responseType === 'bytes') {
|
|
243
|
+
const bodyBytes = new Uint8Array(await response.arrayBuffer());
|
|
244
|
+
return { status: response.status, headers: responseHeaders, body: new TextDecoder().decode(bodyBytes), bodyBytes };
|
|
245
|
+
}
|
|
246
|
+
return { status: response.status, headers: responseHeaders, body: await response.text() };
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Tokens the exchange strategy holds, per root and credential, for the life of the process: an executor
|
|
250
|
+
* is built per deploy and per refresh, so a token kept only inside one would cost a fresh exchange on every
|
|
251
|
+
* write (Bluesky allows 30 sessions in five minutes). In memory only; never written anywhere. */
|
|
252
|
+
const exchangedTokens = new Map();
|
|
253
|
+
/** A rotated credential a seal failed to keep, per custody key: the process spends it (never the spent one the file
|
|
254
|
+
* still holds) until a seal succeeds or the file moves past the value it replaced. In memory only. */
|
|
255
|
+
const unsealedRotations = new Map();
|
|
256
|
+
/** The exchange strategy over the ordinary executor: the token request goes out with the credential's
|
|
257
|
+
* declared fields in its body, the token comes back to this module only, and every non-presigned call
|
|
258
|
+
* carries it. Presigned calls pass through untouched (they carry their own authorization). */
|
|
259
|
+
function exchangeExecute(origin, sealed, auth, vendorHosts, custody) {
|
|
260
|
+
let credential = sealed;
|
|
261
|
+
const anchored = buildRemoteExecute(origin, credential, undefined, vendorHosts);
|
|
262
|
+
const header = auth.header ?? 'authorization';
|
|
263
|
+
const scheme = auth.scheme ?? 'Bearer';
|
|
264
|
+
const retryOn = auth.retryOn ?? [401];
|
|
265
|
+
if (!credential.headers || typeof credential.headers !== 'object')
|
|
266
|
+
throw new Error('remote execute: an exchange credential is sealed with a headers object (it may be empty)');
|
|
267
|
+
// the sealed headers are applied after the caller's, so one naming the token's header would replace it
|
|
268
|
+
if (Object.keys(credential.headers).some((name) => name.toLowerCase() === header.toLowerCase()))
|
|
269
|
+
throw new Error(`remote execute: an exchange credential must not seal its own \`${header}\` header`);
|
|
270
|
+
const field = (name) => {
|
|
271
|
+
const held = credential[name];
|
|
272
|
+
// fail closed, naming the field and never its value
|
|
273
|
+
if (typeof held !== 'string' || held === '')
|
|
274
|
+
throw new Error(`remote execute: the exchange needs \`${name}\` on the sealed credential, and it is absent`);
|
|
275
|
+
return held;
|
|
276
|
+
};
|
|
277
|
+
const key = custody ? `${origin.href}\n${custody.key}` : `${origin.href}\n${JSON.stringify(sealed)}`;
|
|
278
|
+
const exchange = async () => {
|
|
279
|
+
// a rotating secret is read as kept NOW (another executor or process may have rotated it since this one was
|
|
280
|
+
// built), or as this process holds it when its seal failed and the file still holds the spent value. Two
|
|
281
|
+
// processes reading the same value within one exchange's round trip can still both spend it: there is no lock
|
|
282
|
+
// across the call.
|
|
283
|
+
if (auth.rotate && custody) {
|
|
284
|
+
credential = await custody.open();
|
|
285
|
+
const unsealed = unsealedRotations.get(key);
|
|
286
|
+
if (unsealed && credential[auth.rotate.credential] === unsealed.replaced)
|
|
287
|
+
credential = unsealed.credential;
|
|
288
|
+
else
|
|
289
|
+
unsealedRotations.delete(key);
|
|
290
|
+
}
|
|
291
|
+
const fields = {};
|
|
292
|
+
for (const [name, value] of Object.entries(auth.request.body))
|
|
293
|
+
fields[name] = typeof value === 'string' ? value : field(value.credential);
|
|
294
|
+
const body = auth.request.format === 'json' ? JSON.stringify(fields) : new URLSearchParams(fields).toString();
|
|
295
|
+
const headers = { 'content-type': auth.request.format === 'json' ? 'application/json' : 'application/x-www-form-urlencoded' };
|
|
296
|
+
if (auth.request.basicAuth)
|
|
297
|
+
headers.authorization = `Basic ${btoa(`${field(auth.request.basicAuth.user.credential)}:${field(auth.request.basicAuth.password.credential)}`)}`;
|
|
298
|
+
const answer = await anchored({ method: auth.request.method, path: auth.request.path, headers, body });
|
|
299
|
+
if (answer.status < 200 || answer.status >= 300) {
|
|
300
|
+
// the vendor's own reason (an error code, never a secret: the answer carries no token on failure)
|
|
301
|
+
let reason = '';
|
|
302
|
+
try {
|
|
303
|
+
const parsed = JSON.parse(answer.body);
|
|
304
|
+
reason = String(parsed.error ?? parsed.message ?? '').slice(0, 120);
|
|
305
|
+
}
|
|
306
|
+
catch { /* no JSON */ }
|
|
307
|
+
throw new Error(`remote execute: the token exchange answered ${answer.status}${reason ? ` (${reason})` : ''}`);
|
|
308
|
+
}
|
|
309
|
+
let parsed;
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(answer.body);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
throw new Error('remote execute: the token exchange answered no JSON');
|
|
315
|
+
}
|
|
316
|
+
const value = auth.token.split('.').reduce((at, k) => (at && typeof at === 'object' ? at[k] : undefined), parsed);
|
|
317
|
+
if (typeof value !== 'string' || value === '') {
|
|
318
|
+
// an OAuth token endpoint may refuse a grant with a 2xx whose `error` names why (RFC 6749 §5.2's field): carry it, never a secret
|
|
319
|
+
const code = parsed && typeof parsed === 'object' ? parsed.error : undefined;
|
|
320
|
+
throw new Error(`remote execute: the token exchange answered no \`${auth.token}\`${typeof code === 'string' && code !== '' ? ` (${code.slice(0, 120)})` : ''}`);
|
|
321
|
+
}
|
|
322
|
+
if (auth.rotate) {
|
|
323
|
+
const next = auth.rotate.answer.split('.').reduce((at, k) => (at && typeof at === 'object' ? at[k] : undefined), parsed);
|
|
324
|
+
if (typeof next === 'string' && next !== '' && next !== credential[auth.rotate.credential]) {
|
|
325
|
+
// the spent secret is dead at the vendor: held at once, then sealed; a seal that fails is loud (the root would die
|
|
326
|
+
// on its next start), and the token the vendor already answered is kept
|
|
327
|
+
const replaced = String(credential[auth.rotate.credential] ?? '');
|
|
328
|
+
credential = { ...credential, [auth.rotate.credential]: next };
|
|
329
|
+
if (custody) {
|
|
330
|
+
try {
|
|
331
|
+
await custody.seal(credential);
|
|
332
|
+
unsealedRotations.delete(key);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
unsealedRotations.set(key, { replaced, credential });
|
|
336
|
+
console.error(`remote execute: the vendor rotated \`${auth.rotate.credential}\` and sealing the new one failed (${error instanceof Error ? error.message : String(error)}); this process holds it until a seal succeeds`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return value;
|
|
342
|
+
};
|
|
343
|
+
const current = () => {
|
|
344
|
+
let held = exchangedTokens.get(key);
|
|
345
|
+
if (!held) {
|
|
346
|
+
const token = exchange();
|
|
347
|
+
held = { token };
|
|
348
|
+
exchangedTokens.set(key, held);
|
|
349
|
+
const entry = held;
|
|
350
|
+
token.catch(() => { if (exchangedTokens.get(key) === entry)
|
|
351
|
+
exchangedTokens.delete(key); });
|
|
352
|
+
}
|
|
353
|
+
return held;
|
|
354
|
+
};
|
|
355
|
+
const call = async (request, held) => {
|
|
356
|
+
const value = await held.token;
|
|
357
|
+
const headers = {};
|
|
358
|
+
for (const [name, v] of Object.entries(request.headers ?? {}))
|
|
359
|
+
if (name.toLowerCase() !== header.toLowerCase())
|
|
360
|
+
headers[name] = v;
|
|
361
|
+
headers[header] = scheme ? `${scheme} ${value}` : value;
|
|
362
|
+
return anchored({ ...request, headers });
|
|
363
|
+
};
|
|
364
|
+
return async (request) => {
|
|
365
|
+
if (request.presigned)
|
|
366
|
+
return anchored(request);
|
|
367
|
+
const held = current();
|
|
368
|
+
const first = await call(request, held);
|
|
369
|
+
if (!retryOn.includes(first.status))
|
|
370
|
+
return first;
|
|
371
|
+
if (auth.retryCode !== undefined) {
|
|
372
|
+
let code;
|
|
373
|
+
try {
|
|
374
|
+
code = JSON.parse(first.body).error;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
code = undefined;
|
|
378
|
+
}
|
|
379
|
+
if (code !== auth.retryCode)
|
|
380
|
+
return first;
|
|
381
|
+
}
|
|
382
|
+
// exchange again only if the token that failed is still the one held (concurrent failures share one)
|
|
383
|
+
if (exchangedTokens.get(key) === held)
|
|
384
|
+
exchangedTokens.delete(key);
|
|
385
|
+
return call(request, current());
|
|
386
|
+
};
|
|
387
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// A file as a Response, runtime-neutral (the serve seam's helpers,
|
|
2
|
+
// docs/contributing/architecture.md#the-serve-seam): what `new Response(Bun.file(path), init)` answered — the bytes, a content type from
|
|
3
|
+
// the extension unless `init` names one, a 404 when the file is not there.
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
const TYPES = {
|
|
6
|
+
html: 'text/html; charset=utf-8', css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
|
|
7
|
+
json: 'application/json', map: 'application/json', txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8', xml: 'application/xml',
|
|
8
|
+
png: 'image/png', gif: 'image/gif', jpg: 'image/jpeg', jpeg: 'image/jpeg', svg: 'image/svg+xml', webp: 'image/webp', ico: 'image/x-icon',
|
|
9
|
+
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', wasm: 'application/wasm', pdf: 'application/pdf', mp3: 'audio/mpeg', mp4: 'video/mp4', wav: 'audio/wav',
|
|
10
|
+
};
|
|
11
|
+
/** The content type a file's extension implies; `application/octet-stream` for the rest. */
|
|
12
|
+
export function contentTypeOf(path) {
|
|
13
|
+
return TYPES[path.slice(path.lastIndexOf('.') + 1).toLowerCase()] ?? 'application/octet-stream';
|
|
14
|
+
}
|
|
15
|
+
export function fileResponse(path, init) {
|
|
16
|
+
if (!existsSync(path) || !statSync(path).isFile())
|
|
17
|
+
return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
18
|
+
const headers = new Headers(init?.headers);
|
|
19
|
+
if (!headers.has('content-type'))
|
|
20
|
+
headers.set('content-type', contentTypeOf(path));
|
|
21
|
+
return new Response(readFileSync(path), { ...init, headers });
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TwinResource } from './serve.js';
|
|
2
|
+
export type ForkMeta = {
|
|
3
|
+
service: string;
|
|
4
|
+
forkedAt: string;
|
|
5
|
+
baseRoot: string;
|
|
6
|
+
baseView: string;
|
|
7
|
+
baseEventCount: number;
|
|
8
|
+
baseLatestEventId: string | null;
|
|
9
|
+
baseline: TwinResource[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Create a fork of `service` from `fromRoot` into the fresh isolated `toRoot`.
|
|
13
|
+
* References the base log at a position, then records the divergence baseline. `occurredAt` is supplied by the caller (deterministic; no clock).
|
|
14
|
+
*/
|
|
15
|
+
export declare function forkTwin(opts: {
|
|
16
|
+
service: string;
|
|
17
|
+
toRoot: string;
|
|
18
|
+
fromRoot?: string;
|
|
19
|
+
occurredAt: string;
|
|
20
|
+
/** the position in the base's whole log to branch from (contract "Just like Neon", 3); the head when absent */
|
|
21
|
+
at?: number;
|
|
22
|
+
view?: string;
|
|
23
|
+
expectedGeneration?: string;
|
|
24
|
+
}): ForkMeta;
|
|
25
|
+
export declare function readForkMeta(service: string, root: string): ForkMeta;
|
|
26
|
+
export declare function isFork(service: string, root: string): boolean;
|
package/dist/src/fork.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { captureHistory, historyEntries, historyLength, historyPrefix, readHistoryView } from "./history.js";
|
|
2
|
+
// Local branches share parent history at a position and retain a divergence baseline.
|
|
3
|
+
// The kernel registers parent ownership before publishing the pointer. Lifecycle removal
|
|
4
|
+
// refuses while a child depends on that storage; direct filesystem deletion bypasses it.
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { checkParent, withAncestryLock } from "./ancestry.js";
|
|
7
|
+
import { getActiveWorldStore } from "./world-store.js";
|
|
8
|
+
import { writeBranchMeta } from "./log.js";
|
|
9
|
+
import { twinResources } from "./serve.js";
|
|
10
|
+
import { readJsonFile, worldPaths } from "./storage.js";
|
|
11
|
+
// Fields that are bookkeeping, not vendor data — excluded from divergence diffs.
|
|
12
|
+
function forkMetaPath(service, root) {
|
|
13
|
+
return join(dirname(worldPaths(service, root).events), 'fork-meta.json');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create a fork of `service` from `fromRoot` into the fresh isolated `toRoot`.
|
|
17
|
+
* References the base log at a position, then records the divergence baseline. `occurredAt` is supplied by the caller (deterministic; no clock).
|
|
18
|
+
*/
|
|
19
|
+
export function forkTwin(opts) {
|
|
20
|
+
return withAncestryLock(() => {
|
|
21
|
+
// A BRANCH IS A POINTER (log.ts): the base root and the position in its logs, nothing copied.
|
|
22
|
+
// The branch reads the base's parent view and branch log up to that position as its own
|
|
23
|
+
// parent, and records only its own entries from there. The base keeps appending past the
|
|
24
|
+
// position without the branch seeing it; destructive lifecycle operations respect this pin.
|
|
25
|
+
const { service, toRoot } = opts;
|
|
26
|
+
if (getActiveWorldStore().exists(forkMetaPath(service, toRoot))) {
|
|
27
|
+
throw new Error(`fork target already initialized for ${service}: ${toRoot}`);
|
|
28
|
+
}
|
|
29
|
+
const fromRoot = worldPaths(service, opts.fromRoot).root;
|
|
30
|
+
if (opts.expectedGeneration !== undefined)
|
|
31
|
+
checkParent(worldPaths(service, fromRoot).dir, opts.expectedGeneration);
|
|
32
|
+
else
|
|
33
|
+
getActiveWorldStore().mkdir(worldPaths(service, fromRoot).dir);
|
|
34
|
+
const view = opts.view ?? captureHistory(service, fromRoot).view;
|
|
35
|
+
const descriptor = readHistoryView(worldPaths(service, fromRoot).dir, view);
|
|
36
|
+
const head = historyLength(descriptor.layout);
|
|
37
|
+
const at = opts.at ?? head;
|
|
38
|
+
if (!Number.isInteger(at) || at < 0 || at > head)
|
|
39
|
+
throw new Error(`Invalid branch position ${at}; parent head is ${head}`);
|
|
40
|
+
const entries = historyEntries(descriptor);
|
|
41
|
+
if (at > 0 && at < entries.length && entries[at]?.batch && entries[at]?.batch === entries[at - 1]?.batch)
|
|
42
|
+
throw new Error(`position ${at} falls inside observation ${entries[at].batch}`);
|
|
43
|
+
const log = entries.slice(0, at);
|
|
44
|
+
const selected = historyPrefix(descriptor, at);
|
|
45
|
+
writeBranchMeta(service, { parent: { at: fromRoot, position: at, view }, branchedAt: opts.occurredAt, ...(selected.origin ? { origin: selected.origin } : {}) }, toRoot);
|
|
46
|
+
const baseline = twinResources(service, toRoot).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
47
|
+
const meta = {
|
|
48
|
+
service,
|
|
49
|
+
forkedAt: opts.occurredAt,
|
|
50
|
+
baseRoot: fromRoot,
|
|
51
|
+
baseView: view,
|
|
52
|
+
baseEventCount: at,
|
|
53
|
+
baseLatestEventId: log.at(-1)?.id ?? null,
|
|
54
|
+
baseline,
|
|
55
|
+
};
|
|
56
|
+
getActiveWorldStore().write(forkMetaPath(service, toRoot), `${JSON.stringify(meta, null, 2)}\n`);
|
|
57
|
+
return meta;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function readForkMeta(service, root) {
|
|
61
|
+
const path = forkMetaPath(service, root);
|
|
62
|
+
if (!getActiveWorldStore().exists(path))
|
|
63
|
+
throw new Error(`not a fork (no fork-meta.json) for ${service}: ${root}`);
|
|
64
|
+
return readJsonFile(path);
|
|
65
|
+
}
|
|
66
|
+
export function isFork(service, root) {
|
|
67
|
+
return getActiveWorldStore().exists(forkMetaPath(service, root));
|
|
68
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { GitObjectStore } from './objects.js';
|
|
2
|
+
/** A best common ancestor of `a` and `b` (one that is not an ancestor of another common ancestor), or null. */
|
|
3
|
+
export declare function mergeBase(store: GitObjectStore, a: string, b: string, limit?: number): Promise<string | null>;
|
|
4
|
+
export type TreeChange = {
|
|
5
|
+
path: string;
|
|
6
|
+
status: 'added' | 'removed' | 'modified';
|
|
7
|
+
oldSha?: string;
|
|
8
|
+
newSha?: string;
|
|
9
|
+
oldMode?: string;
|
|
10
|
+
newMode?: string;
|
|
11
|
+
};
|
|
12
|
+
/** Recursive tree compare between two commits or trees (paths in the post-image order, no rename pairing — like `diff-tree -r` without -M). */
|
|
13
|
+
export declare function diffTrees(store: GitObjectStore, from: string | null, to: string, prefix?: string): Promise<TreeChange[]>;
|
|
14
|
+
export type Edit = {
|
|
15
|
+
kind: ' ' | '-' | '+';
|
|
16
|
+
line: string;
|
|
17
|
+
};
|
|
18
|
+
/** A shortest edit script between two texts (Myers, O((N+M)D)). */
|
|
19
|
+
export declare function lineDiff(oldText: string, newText: string): Edit[];
|
|
20
|
+
/** The unified-diff hunks (the body GitHub serves as a file's `patch`), 3 lines of context. */
|
|
21
|
+
export declare function unifiedPatch(oldText: string, newText: string, context?: number): string;
|
|
22
|
+
export type DiffFile = {
|
|
23
|
+
filename: string;
|
|
24
|
+
status: 'added' | 'removed' | 'modified';
|
|
25
|
+
additions: number;
|
|
26
|
+
deletions: number;
|
|
27
|
+
sha?: string;
|
|
28
|
+
patch?: string;
|
|
29
|
+
};
|
|
30
|
+
/** GitHub's file list between `base` and `head` (three-dot: from merge-base(base, head), or base when histories are unrelated), with counts and patches. */
|
|
31
|
+
export declare function diffFiles(store: GitObjectStore, baseSha: string, headSha: string, opts?: {
|
|
32
|
+
patch?: boolean;
|
|
33
|
+
}): Promise<DiffFile[] | null>;
|
|
34
|
+
/** Three-way line merge. Return null for overlapping edits; preserve exact line endings.
|
|
35
|
+
* Binary payloads belong to the caller's conflict path, never a text decoder's replacement. */
|
|
36
|
+
export declare function mergeText(base: string, ours: string, theirs: string): string | null;
|