@volter/world-core 3.0.11 → 3.0.13
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/attach.cjs +28 -3
- package/dist/attach.cjs +28 -3
- package/dist/http-bridge.cjs +92 -0
- package/dist/src/derived-real.js +25 -4
- package/dist/src/pack-fetch.js +18 -5
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +1 -0
- package/http-bridge.cjs +92 -0
- package/package.json +3 -1
- package/src/derived-real.ts +15 -3
- package/src/pack-fetch.ts +15 -4
- package/src/runtime.ts +1 -0
package/attach.cjs
CHANGED
|
@@ -25,15 +25,40 @@ if (ref) {
|
|
|
25
25
|
// a blocking fetch at module load: spawn a child that prints the manifest
|
|
26
26
|
const probe = spawnSync(
|
|
27
27
|
process.execPath,
|
|
28
|
-
|
|
28
|
+
// the token by the child's environment, never its arguments: another user on this machine can list arguments
|
|
29
|
+
['-e', 'const t=process.env.VOLTER_ATTACH_PROBE_TOKEN;fetch(process.argv[1],t?{headers:{authorization:"Bearer "+t}}:undefined).then(r=>{if(!r.ok)throw new Error(r.status);return r.text()}).then(t=>process.stdout.write(t)).catch(e=>{console.error(String(e));process.exit(1)})', url],
|
|
29
30
|
// the child must NOT inherit NODE_OPTIONS/VOLTER_WORLD — it would
|
|
30
31
|
// re-run this very file at its own startup (a spawn recursion bomb)
|
|
31
|
-
{ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', VOLTER_WORLD: '' } },
|
|
32
|
+
{ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', VOLTER_WORLD: '', ...(token ? { VOLTER_ATTACH_PROBE_TOKEN: token } : {}) } },
|
|
32
33
|
);
|
|
33
34
|
if (probe.status !== 0) {
|
|
34
35
|
throw new Error(`[twin-attach] could not fetch the world manifest from ${url}: ${(probe.stderr || '').trim()}`);
|
|
35
36
|
}
|
|
36
37
|
const manifest = JSON.parse(probe.stdout);
|
|
38
|
+
// a served World's twins as loopback listeners that add its key (./http-bridge.cjs), so a client with its own HTTP
|
|
39
|
+
// stack (wrangler's undici, which neither patch reaches) reaches them as it would a local World's: the twin URLs and
|
|
40
|
+
// every endpoint variable naming a twin's place point at the listener. Ports are chosen by a blocking child, the
|
|
41
|
+
// listeners bound on the next turn, before any client can connect, and never hold the process open.
|
|
42
|
+
// the key the World echoed to this caller, as the injector carries it; else the token given
|
|
43
|
+
const key = manifest.env?.VOLTER_TWINS_KEY ?? token;
|
|
44
|
+
const served = Object.entries(manifest.vendors ?? {}).filter(([, origin]) => key && /^https?:\/\//.test(String(origin)));
|
|
45
|
+
if (served.length) {
|
|
46
|
+
const picked = spawnSync(
|
|
47
|
+
process.execPath,
|
|
48
|
+
['-e', 'const n=require("node:net");const k=+process.argv[1];const s=[];const d=()=>{if(s.length<k){const v=n.createServer().listen(0,"127.0.0.1",()=>{s.push(v);d()})}else{process.stdout.write(s.map(v=>v.address().port).join(","));s.forEach(v=>v.close())}};d()', String(served.length)],
|
|
49
|
+
{ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', VOLTER_WORLD: '' } },
|
|
50
|
+
);
|
|
51
|
+
if (picked.status !== 0) throw new Error(`[twin-attach] could not choose loopback ports for the world's twins: ${(picked.stderr || '').trim()}`);
|
|
52
|
+
const ports = picked.stdout.split(',').map(Number);
|
|
53
|
+
const { bridgeHttp, bridgeSecret, pointAt } = require('./http-bridge.cjs');
|
|
54
|
+
served.forEach(([vendor], i) => {
|
|
55
|
+
const secret = bridgeSecret();
|
|
56
|
+
const at = pointAt(manifest, vendor, `http://127.0.0.1:${ports[i]}/${secret}`);
|
|
57
|
+
bridgeHttp(at, key, ports[i], secret, manifest.ca || undefined).then((server) => server.unref(), (error) => {
|
|
58
|
+
process.stderr.write(`[twin-attach] ${at}: could not listen on 127.0.0.1:${ports[i]}: ${error.message}\n`);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
37
62
|
for (const [vendor, origin] of Object.entries(manifest.vendors ?? {})) {
|
|
38
63
|
const key = `${vendor.toUpperCase()}_TWIN_URL`;
|
|
39
64
|
if (process.env[key] === undefined) process.env[key] = origin;
|
|
@@ -55,7 +80,7 @@ if (ref) {
|
|
|
55
80
|
if (picked.status !== 0) throw new Error(`[twin-attach] could not choose loopback ports for the world's streams: ${(picked.stderr || '').trim()}`);
|
|
56
81
|
const ports = picked.stdout.split(',').map(Number);
|
|
57
82
|
const { bridgeStream, streamEnv } = require('./stream-bridge.cjs');
|
|
58
|
-
const streamToken =
|
|
83
|
+
const streamToken = manifest.env?.VOLTER_TWINS_KEY ?? token ?? '';
|
|
59
84
|
streams.forEach((stream, i) => {
|
|
60
85
|
for (const [key, value] of Object.entries(streamEnv(stream.env, ports[i]))) {
|
|
61
86
|
if (process.env[key] === undefined) process.env[key] = value;
|
package/dist/attach.cjs
CHANGED
|
@@ -25,15 +25,40 @@ if (ref) {
|
|
|
25
25
|
// a blocking fetch at module load: spawn a child that prints the manifest
|
|
26
26
|
const probe = spawnSync(
|
|
27
27
|
process.execPath,
|
|
28
|
-
|
|
28
|
+
// the token by the child's environment, never its arguments: another user on this machine can list arguments
|
|
29
|
+
['-e', 'const t=process.env.VOLTER_ATTACH_PROBE_TOKEN;fetch(process.argv[1],t?{headers:{authorization:"Bearer "+t}}:undefined).then(r=>{if(!r.ok)throw new Error(r.status);return r.text()}).then(t=>process.stdout.write(t)).catch(e=>{console.error(String(e));process.exit(1)})', url],
|
|
29
30
|
// the child must NOT inherit NODE_OPTIONS/VOLTER_WORLD — it would
|
|
30
31
|
// re-run this very file at its own startup (a spawn recursion bomb)
|
|
31
|
-
{ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', VOLTER_WORLD: '' } },
|
|
32
|
+
{ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', VOLTER_WORLD: '', ...(token ? { VOLTER_ATTACH_PROBE_TOKEN: token } : {}) } },
|
|
32
33
|
);
|
|
33
34
|
if (probe.status !== 0) {
|
|
34
35
|
throw new Error(`[twin-attach] could not fetch the world manifest from ${url}: ${(probe.stderr || '').trim()}`);
|
|
35
36
|
}
|
|
36
37
|
const manifest = JSON.parse(probe.stdout);
|
|
38
|
+
// a served World's twins as loopback listeners that add its key (./http-bridge.cjs), so a client with its own HTTP
|
|
39
|
+
// stack (wrangler's undici, which neither patch reaches) reaches them as it would a local World's: the twin URLs and
|
|
40
|
+
// every endpoint variable naming a twin's place point at the listener. Ports are chosen by a blocking child, the
|
|
41
|
+
// listeners bound on the next turn, before any client can connect, and never hold the process open.
|
|
42
|
+
// the key the World echoed to this caller, as the injector carries it; else the token given
|
|
43
|
+
const key = manifest.env?.VOLTER_TWINS_KEY ?? token;
|
|
44
|
+
const served = Object.entries(manifest.vendors ?? {}).filter(([, origin]) => key && /^https?:\/\//.test(String(origin)));
|
|
45
|
+
if (served.length) {
|
|
46
|
+
const picked = spawnSync(
|
|
47
|
+
process.execPath,
|
|
48
|
+
['-e', 'const n=require("node:net");const k=+process.argv[1];const s=[];const d=()=>{if(s.length<k){const v=n.createServer().listen(0,"127.0.0.1",()=>{s.push(v);d()})}else{process.stdout.write(s.map(v=>v.address().port).join(","));s.forEach(v=>v.close())}};d()', String(served.length)],
|
|
49
|
+
{ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', VOLTER_WORLD: '' } },
|
|
50
|
+
);
|
|
51
|
+
if (picked.status !== 0) throw new Error(`[twin-attach] could not choose loopback ports for the world's twins: ${(picked.stderr || '').trim()}`);
|
|
52
|
+
const ports = picked.stdout.split(',').map(Number);
|
|
53
|
+
const { bridgeHttp, bridgeSecret, pointAt } = require('./http-bridge.cjs');
|
|
54
|
+
served.forEach(([vendor], i) => {
|
|
55
|
+
const secret = bridgeSecret();
|
|
56
|
+
const at = pointAt(manifest, vendor, `http://127.0.0.1:${ports[i]}/${secret}`);
|
|
57
|
+
bridgeHttp(at, key, ports[i], secret, manifest.ca || undefined).then((server) => server.unref(), (error) => {
|
|
58
|
+
process.stderr.write(`[twin-attach] ${at}: could not listen on 127.0.0.1:${ports[i]}: ${error.message}\n`);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
37
62
|
for (const [vendor, origin] of Object.entries(manifest.vendors ?? {})) {
|
|
38
63
|
const key = `${vendor.toUpperCase()}_TWIN_URL`;
|
|
39
64
|
if (process.env[key] === undefined) process.env[key] = origin;
|
|
@@ -55,7 +80,7 @@ if (ref) {
|
|
|
55
80
|
if (picked.status !== 0) throw new Error(`[twin-attach] could not choose loopback ports for the world's streams: ${(picked.stderr || '').trim()}`);
|
|
56
81
|
const ports = picked.stdout.split(',').map(Number);
|
|
57
82
|
const { bridgeStream, streamEnv } = require('./stream-bridge.cjs');
|
|
58
|
-
const streamToken =
|
|
83
|
+
const streamToken = manifest.env?.VOLTER_TWINS_KEY ?? token ?? '';
|
|
59
84
|
streams.forEach((stream, i) => {
|
|
60
85
|
for (const [key, value] of Object.entries(streamEnv(stream.env, ports[i]))) {
|
|
61
86
|
if (process.env[key] === undefined) process.env[key] = value;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// THE HTTP BRIDGE: a served World's twins as loopback listeners, for a client the injector cannot reach because it brings
|
|
2
|
+
// its own HTTP stack (wrangler bundles undici; a Go binary, curl). Each forwards to its twin's place in the World with
|
|
3
|
+
// the World's key added, so the client needs only its vendor credential, as at a local World's twin. Used by the attach
|
|
4
|
+
// entry, beside the stream bridge (./stream-bridge.cjs).
|
|
5
|
+
//
|
|
6
|
+
// The key is the World's, so the listener lends it only to whoever holds the listener's own address: a random path
|
|
7
|
+
// prefix the attaching process's environment carries, as it carries the key. A request without it (another user on
|
|
8
|
+
// this machine, a page in a browser) is refused, as is any that names a path outside the twin's place.
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const http = require('node:http');
|
|
12
|
+
const https = require('node:https');
|
|
13
|
+
const { randomBytes } = require('node:crypto');
|
|
14
|
+
|
|
15
|
+
// the transports as Node has them, taken before the injector patches them: the bridge adds the key itself
|
|
16
|
+
const request = { 'http:': http.request, 'https:': https.request };
|
|
17
|
+
|
|
18
|
+
/** A listener's secret prefix: the path every request to it begins with. */
|
|
19
|
+
function bridgeSecret() { return randomBytes(18).toString('hex'); }
|
|
20
|
+
|
|
21
|
+
/** Serve the twin at `target` (its place in the World, `<origin>/<org>/<world>/<vendor>`) on 127.0.0.1:`port` (0: any),
|
|
22
|
+
* under `/<secret>`. A redirect to the twin's place is answered at the bridge's own address. */
|
|
23
|
+
function bridgeHttp(target, key, port = 0, secret = bridgeSecret(), ca = undefined) {
|
|
24
|
+
const base = new URL(target.replace(/\/$/, ''));
|
|
25
|
+
const mine = `/${secret}`;
|
|
26
|
+
const refuse = (res, status, why) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }); res.end(`[twin-http] ${why}\n`); };
|
|
27
|
+
const server = http.createServer((req, res) => {
|
|
28
|
+
// a browser's request carries its page's origin: never the attaching process's own
|
|
29
|
+
if (req.headers.origin) return refuse(res, 403, 'a page in a browser does not borrow the World\'s key');
|
|
30
|
+
const raw = req.url ?? '';
|
|
31
|
+
if (!(raw === mine || raw.startsWith(`${mine}/`) || raw.startsWith(`${mine}?`))) return refuse(res, 404, 'not this listener\'s address');
|
|
32
|
+
const rest = raw.slice(mine.length);
|
|
33
|
+
const to = new URL(`${base.pathname}${rest.startsWith('/') || rest === '' ? rest : `/${rest}`}`, base.origin);
|
|
34
|
+
// the URL resolved (dot segments, encoded or not): still under the twin's place, or refused
|
|
35
|
+
if (to.pathname !== base.pathname && !to.pathname.startsWith(`${base.pathname}/`)) return refuse(res, 403, 'outside the twin\'s place');
|
|
36
|
+
// the host and path the client sent, which a signature over the request (SigV4's canonical request) covers
|
|
37
|
+
const headers = { ...req.headers, host: base.host, 'x-twins-key': key, 'x-volter-twin-original-host': req.headers.host ?? '', 'x-volter-original-path': raw.split('?')[0] };
|
|
38
|
+
for (const h of ['connection', 'keep-alive', 'proxy-connection', 'upgrade']) delete headers[h];
|
|
39
|
+
const here = `http://127.0.0.1:${server.address().port}${mine}`;
|
|
40
|
+
// a World served behind its session CA is trusted by it here, as the injector's forwards are
|
|
41
|
+
const up = request[base.protocol](to, { method: req.method, headers, ...(ca && base.protocol === 'https:' ? { ca } : {}) }, (answer) => {
|
|
42
|
+
const out = { ...answer.headers };
|
|
43
|
+
delete out.connection; delete out['keep-alive'];
|
|
44
|
+
if (typeof out.location === 'string') {
|
|
45
|
+
// a redirect within the twin's place, absolute or by path, is answered at the listener
|
|
46
|
+
const loc = out.location;
|
|
47
|
+
if (loc === base.href || loc.startsWith(`${base.href}/`) || loc.startsWith(`${base.href}?`)) out.location = `${here}${loc.slice(base.href.length)}`;
|
|
48
|
+
else if (loc === base.pathname || loc.startsWith(`${base.pathname}/`)) out.location = `${here}${loc.slice(base.pathname.length)}`;
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(answer.statusCode ?? 502, answer.statusMessage, out);
|
|
51
|
+
answer.on('error', () => res.destroy());
|
|
52
|
+
answer.pipe(res);
|
|
53
|
+
});
|
|
54
|
+
up.on('error', (error) => { if (res.headersSent) res.destroy(); else refuse(res, 502, `${base.href}: ${error.message}`); });
|
|
55
|
+
req.on('error', () => up.destroy());
|
|
56
|
+
res.on('close', () => { if (!res.writableFinished) up.destroy(); });
|
|
57
|
+
req.pipe(up);
|
|
58
|
+
});
|
|
59
|
+
// a socket a client upgrades (a log tail's WebSocket) is not carried yet: said, not dropped
|
|
60
|
+
server.on('upgrade', (_req, socket) => { socket.end('HTTP/1.1 501 Not Implemented\r\ncontent-type: text/plain\r\n\r\n[twin-http] a WebSocket is not carried through the listener\n'); });
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
server.once('error', reject);
|
|
63
|
+
server.listen(port, '127.0.0.1', () => resolve(server));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Point a World manifest's twin at a local listener: its twin URL, and every endpoint variable naming its place (itself,
|
|
68
|
+
* or a path or query under it; never another twin whose name it begins). */
|
|
69
|
+
function pointAt(manifest, vendor, local) {
|
|
70
|
+
const at = String(manifest.vendors[vendor]).replace(/\/$/, '');
|
|
71
|
+
const under = (value) => value === at || value.startsWith(`${at}/`) || value.startsWith(`${at}?`);
|
|
72
|
+
manifest.vendors[vendor] = local;
|
|
73
|
+
for (const [name, value] of Object.entries(manifest.env ?? {})) if (typeof value === 'string' && under(value)) manifest.env[name] = `${local}${value.slice(at.length)}`;
|
|
74
|
+
return at;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every served twin of a World manifest bridged (`volter-world attach`, a command in any language): its listener up, and
|
|
78
|
+
* the manifest pointed at it. Resolves to the listeners, unref'd. */
|
|
79
|
+
async function bridgeTwins(manifest, key, ca = undefined) {
|
|
80
|
+
const servers = [];
|
|
81
|
+
for (const [vendor, origin] of Object.entries(manifest.vendors ?? {})) {
|
|
82
|
+
if (!key || !/^https?:\/\//.test(String(origin))) continue;
|
|
83
|
+
const secret = bridgeSecret();
|
|
84
|
+
const server = await bridgeHttp(String(origin), key, 0, secret, ca);
|
|
85
|
+
server.unref();
|
|
86
|
+
pointAt(manifest, vendor, `http://127.0.0.1:${server.address().port}/${secret}`);
|
|
87
|
+
servers.push(server);
|
|
88
|
+
}
|
|
89
|
+
return servers;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { bridgeHttp, bridgeSecret, bridgeTwins, pointAt };
|
package/dist/src/derived-real.js
CHANGED
|
@@ -325,6 +325,9 @@ function refreshOf(units, service) {
|
|
|
325
325
|
return async (execute, opts) => {
|
|
326
326
|
const observed = [];
|
|
327
327
|
const complete = [];
|
|
328
|
+
// what the root's credential may not read (a token scoped to some of the account): left as the World holds it, said
|
|
329
|
+
const unread = [];
|
|
330
|
+
let answered = 0;
|
|
328
331
|
for (const unit of units) {
|
|
329
332
|
const { manifest: m, surface } = unit;
|
|
330
333
|
// parents before children, however deep
|
|
@@ -344,17 +347,31 @@ function refreshOf(units, service) {
|
|
|
344
347
|
const limit = m.list?.limit && (op.query ?? []).some((q) => q.name === m.list.limit.param) ? { [m.list.limit.param]: String(m.list.limit.max) } : {};
|
|
345
348
|
let page = { ...limit };
|
|
346
349
|
for (let taken = 0;;) {
|
|
347
|
-
|
|
350
|
+
let unfilled = false;
|
|
351
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n) => { const v = n === decl.parent?.param && parent !== undefined ? String(parent) : ''; if (!v)
|
|
352
|
+
unfilled = true; return encodeURIComponent(v); });
|
|
348
353
|
// a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
|
|
349
354
|
const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) } : {};
|
|
350
355
|
const q = new URLSearchParams({ ...page, ...byQuery });
|
|
351
356
|
const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path, ...(unit.lane ? { lane: unit.lane } : {}) });
|
|
352
|
-
if (answer.status === 401
|
|
353
|
-
throw new Error(
|
|
357
|
+
if (answer.status === 401)
|
|
358
|
+
throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
359
|
+
if (answer.status === 403) {
|
|
360
|
+
unread.push({ type, path, status: 403 });
|
|
361
|
+
whole = false;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
354
364
|
if (answer.status === 429)
|
|
355
365
|
throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
366
|
+
// one subject read by its parent and not there (an account with no workers.dev subdomain): nothing to observe. A
|
|
367
|
+
// path missing a parameter (an empty segment) is the kernel's mistake, never the vendor's absence
|
|
368
|
+
if ('get' in scope && answer.status === 404 && !unfilled) {
|
|
369
|
+
answered += 1;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
356
372
|
if (answer.status >= 400)
|
|
357
373
|
throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
374
|
+
answered += 1;
|
|
358
375
|
const parsed = parse(answer);
|
|
359
376
|
const items = 'get' in scope ? [object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {}]
|
|
360
377
|
: 'items' in scope && scope.items ? (at(parsed, scope.items) ?? []).map((x) => object(x)).filter((x) => x !== undefined) : itemsOf(op, parsed);
|
|
@@ -411,8 +428,12 @@ function refreshOf(units, service) {
|
|
|
411
428
|
complete.push({ type, field: decl.parent.field, within: parents.map(String) });
|
|
412
429
|
}
|
|
413
430
|
}
|
|
431
|
+
// every read refused is a credential the vendor refuses, never an account that holds nothing
|
|
432
|
+
if (unread.length && !answered)
|
|
433
|
+
throw new Error(`403: the vendor refused the root's credential for every read (${unread.map((u) => u.path).join(', ')}); nothing folded`);
|
|
414
434
|
adopt(service, opts.root, units, observed);
|
|
415
|
-
|
|
435
|
+
const report = observeResources(service, observed, { ...(opts.root !== undefined ? { root: opts.root } : {}), complete });
|
|
436
|
+
return unread.length ? { ...report, unread } : report;
|
|
416
437
|
};
|
|
417
438
|
}
|
|
418
439
|
/** A vendor's subject's stored id: its key template filled from its fields (its parent's among them), else its id
|
package/dist/src/pack-fetch.js
CHANGED
|
@@ -69,12 +69,18 @@ const DECODE = { gzip: gunzipSync, zstd: zstdDecompressSync, deflate: inflateSyn
|
|
|
69
69
|
/** A request with its body decoded as its Content-Encoding says, when the manifest's `encodings` reads that coding;
|
|
70
70
|
* undefined when the bytes are not what the header names. */
|
|
71
71
|
async function decoded(m, request) {
|
|
72
|
-
|
|
73
|
-
if (!m.encodings?.request?.includes(coding) || request.method === 'GET' || request.method === 'HEAD')
|
|
72
|
+
if (request.method === 'GET' || request.method === 'HEAD' || request.body === null)
|
|
74
73
|
return request;
|
|
74
|
+
// the body read once, here, and every part of the pack given a request made again from its bytes: a served request's
|
|
75
|
+
// body read through a clone and then read again (an `around` that checks a body before its handler does) tees a stream
|
|
76
|
+
// whose second branch a Bun server never finishes, and the request hung
|
|
77
|
+
const raw = new Uint8Array(await request.arrayBuffer());
|
|
78
|
+
const coding = (request.headers.get('content-encoding') ?? '').trim().toLowerCase();
|
|
79
|
+
if (!m.encodings?.request?.includes(coding))
|
|
80
|
+
return new Request(request.url, { method: request.method, headers: request.headers, body: raw });
|
|
75
81
|
let body;
|
|
76
82
|
try {
|
|
77
|
-
body = DECODE[coding](
|
|
83
|
+
body = DECODE[coding](raw);
|
|
78
84
|
}
|
|
79
85
|
catch {
|
|
80
86
|
return undefined;
|
|
@@ -213,11 +219,16 @@ export function createPackFetch(parts, options = {}) {
|
|
|
213
219
|
const workspaces = () => [...(m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path })), ...lanesWorkspaces(parts.lanes ?? {})];
|
|
214
220
|
/** Whether a built screen a person views (a workspace or a flow, never content an API client reads) draws a url: its host
|
|
215
221
|
* one the screen names, its path the screen's or under it (a root screen draws its root alone). */
|
|
222
|
+
// by what the pack declares built (a screen drawn by another's handler, as X's profile is by its web pages, is built)
|
|
223
|
+
const viewed = (m.screens ?? []).filter((s) => s.status === 'done' && s.kind !== 'content').map((s) => {
|
|
224
|
+
const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
|
|
225
|
+
return { root: s.path.replace(/\/+$/, '') === '', host: (h) => hosts.some((match) => match(h)), path: pathMatcher(s.path, true) };
|
|
226
|
+
});
|
|
216
227
|
const drawsUrl = (at) => {
|
|
217
228
|
try {
|
|
218
229
|
const u = new URL(at);
|
|
219
230
|
const path = u.pathname.replace(/\/+$/, '') || '/';
|
|
220
|
-
return
|
|
231
|
+
return viewed.some((s) => s.host(u.hostname.toLowerCase()) && (s.root ? path === '/' : Boolean(s.path(path))));
|
|
221
232
|
}
|
|
222
233
|
catch {
|
|
223
234
|
return false;
|
|
@@ -246,7 +257,9 @@ export function createPackFetch(parts, options = {}) {
|
|
|
246
257
|
return request;
|
|
247
258
|
// the path the client sent, which a signature over the request (SigV4's canonical URI) covers
|
|
248
259
|
const headers = new Headers(request.headers);
|
|
249
|
-
|
|
260
|
+
// the path as first sent: a front before this (an attach bridge) may have named the one its client signed
|
|
261
|
+
if (!headers.has(ORIGINAL_PATH_HEADER))
|
|
262
|
+
headers.set(ORIGINAL_PATH_HEADER, url.pathname);
|
|
250
263
|
if (Object.keys(groups).length)
|
|
251
264
|
headers.set(PREFIX_PARAMS_HEADER, JSON.stringify(groups));
|
|
252
265
|
url.pathname = path;
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrit
|
|
|
3
3
|
export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.js';
|
|
4
4
|
export { WORLD_CLOCK_ENV, worldNow } from './world-clock.js';
|
|
5
5
|
export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.js';
|
|
6
|
+
export { ORIGINAL_PATH_HEADER } from './sigv4.js';
|
|
6
7
|
export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.js';
|
|
7
8
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.js';
|
|
8
9
|
export { compileSurface, createDerivedFetch, matchOperation } from './derived.js';
|
package/dist/src/runtime.js
CHANGED
|
@@ -11,6 +11,7 @@ export { clearRegistry, getPack, hasPack, listPacks, packOf, registerPack, pullP
|
|
|
11
11
|
export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from "./scenario.js";
|
|
12
12
|
export { WORLD_CLOCK_ENV, worldNow } from "./world-clock.js";
|
|
13
13
|
export { WORLD_ENV_NAMES_ENV, worldEnvValue } from "./world-env.js";
|
|
14
|
+
export { ORIGINAL_PATH_HEADER } from "./sigv4.js";
|
|
14
15
|
export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from "./twin-fetch.js";
|
|
15
16
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
|
|
16
17
|
export { compileSurface, createDerivedFetch, matchOperation } from "./derived.js";
|
package/http-bridge.cjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// THE HTTP BRIDGE: a served World's twins as loopback listeners, for a client the injector cannot reach because it brings
|
|
2
|
+
// its own HTTP stack (wrangler bundles undici; a Go binary, curl). Each forwards to its twin's place in the World with
|
|
3
|
+
// the World's key added, so the client needs only its vendor credential, as at a local World's twin. Used by the attach
|
|
4
|
+
// entry, beside the stream bridge (./stream-bridge.cjs).
|
|
5
|
+
//
|
|
6
|
+
// The key is the World's, so the listener lends it only to whoever holds the listener's own address: a random path
|
|
7
|
+
// prefix the attaching process's environment carries, as it carries the key. A request without it (another user on
|
|
8
|
+
// this machine, a page in a browser) is refused, as is any that names a path outside the twin's place.
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const http = require('node:http');
|
|
12
|
+
const https = require('node:https');
|
|
13
|
+
const { randomBytes } = require('node:crypto');
|
|
14
|
+
|
|
15
|
+
// the transports as Node has them, taken before the injector patches them: the bridge adds the key itself
|
|
16
|
+
const request = { 'http:': http.request, 'https:': https.request };
|
|
17
|
+
|
|
18
|
+
/** A listener's secret prefix: the path every request to it begins with. */
|
|
19
|
+
function bridgeSecret() { return randomBytes(18).toString('hex'); }
|
|
20
|
+
|
|
21
|
+
/** Serve the twin at `target` (its place in the World, `<origin>/<org>/<world>/<vendor>`) on 127.0.0.1:`port` (0: any),
|
|
22
|
+
* under `/<secret>`. A redirect to the twin's place is answered at the bridge's own address. */
|
|
23
|
+
function bridgeHttp(target, key, port = 0, secret = bridgeSecret(), ca = undefined) {
|
|
24
|
+
const base = new URL(target.replace(/\/$/, ''));
|
|
25
|
+
const mine = `/${secret}`;
|
|
26
|
+
const refuse = (res, status, why) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }); res.end(`[twin-http] ${why}\n`); };
|
|
27
|
+
const server = http.createServer((req, res) => {
|
|
28
|
+
// a browser's request carries its page's origin: never the attaching process's own
|
|
29
|
+
if (req.headers.origin) return refuse(res, 403, 'a page in a browser does not borrow the World\'s key');
|
|
30
|
+
const raw = req.url ?? '';
|
|
31
|
+
if (!(raw === mine || raw.startsWith(`${mine}/`) || raw.startsWith(`${mine}?`))) return refuse(res, 404, 'not this listener\'s address');
|
|
32
|
+
const rest = raw.slice(mine.length);
|
|
33
|
+
const to = new URL(`${base.pathname}${rest.startsWith('/') || rest === '' ? rest : `/${rest}`}`, base.origin);
|
|
34
|
+
// the URL resolved (dot segments, encoded or not): still under the twin's place, or refused
|
|
35
|
+
if (to.pathname !== base.pathname && !to.pathname.startsWith(`${base.pathname}/`)) return refuse(res, 403, 'outside the twin\'s place');
|
|
36
|
+
// the host and path the client sent, which a signature over the request (SigV4's canonical request) covers
|
|
37
|
+
const headers = { ...req.headers, host: base.host, 'x-twins-key': key, 'x-volter-twin-original-host': req.headers.host ?? '', 'x-volter-original-path': raw.split('?')[0] };
|
|
38
|
+
for (const h of ['connection', 'keep-alive', 'proxy-connection', 'upgrade']) delete headers[h];
|
|
39
|
+
const here = `http://127.0.0.1:${server.address().port}${mine}`;
|
|
40
|
+
// a World served behind its session CA is trusted by it here, as the injector's forwards are
|
|
41
|
+
const up = request[base.protocol](to, { method: req.method, headers, ...(ca && base.protocol === 'https:' ? { ca } : {}) }, (answer) => {
|
|
42
|
+
const out = { ...answer.headers };
|
|
43
|
+
delete out.connection; delete out['keep-alive'];
|
|
44
|
+
if (typeof out.location === 'string') {
|
|
45
|
+
// a redirect within the twin's place, absolute or by path, is answered at the listener
|
|
46
|
+
const loc = out.location;
|
|
47
|
+
if (loc === base.href || loc.startsWith(`${base.href}/`) || loc.startsWith(`${base.href}?`)) out.location = `${here}${loc.slice(base.href.length)}`;
|
|
48
|
+
else if (loc === base.pathname || loc.startsWith(`${base.pathname}/`)) out.location = `${here}${loc.slice(base.pathname.length)}`;
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(answer.statusCode ?? 502, answer.statusMessage, out);
|
|
51
|
+
answer.on('error', () => res.destroy());
|
|
52
|
+
answer.pipe(res);
|
|
53
|
+
});
|
|
54
|
+
up.on('error', (error) => { if (res.headersSent) res.destroy(); else refuse(res, 502, `${base.href}: ${error.message}`); });
|
|
55
|
+
req.on('error', () => up.destroy());
|
|
56
|
+
res.on('close', () => { if (!res.writableFinished) up.destroy(); });
|
|
57
|
+
req.pipe(up);
|
|
58
|
+
});
|
|
59
|
+
// a socket a client upgrades (a log tail's WebSocket) is not carried yet: said, not dropped
|
|
60
|
+
server.on('upgrade', (_req, socket) => { socket.end('HTTP/1.1 501 Not Implemented\r\ncontent-type: text/plain\r\n\r\n[twin-http] a WebSocket is not carried through the listener\n'); });
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
server.once('error', reject);
|
|
63
|
+
server.listen(port, '127.0.0.1', () => resolve(server));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Point a World manifest's twin at a local listener: its twin URL, and every endpoint variable naming its place (itself,
|
|
68
|
+
* or a path or query under it; never another twin whose name it begins). */
|
|
69
|
+
function pointAt(manifest, vendor, local) {
|
|
70
|
+
const at = String(manifest.vendors[vendor]).replace(/\/$/, '');
|
|
71
|
+
const under = (value) => value === at || value.startsWith(`${at}/`) || value.startsWith(`${at}?`);
|
|
72
|
+
manifest.vendors[vendor] = local;
|
|
73
|
+
for (const [name, value] of Object.entries(manifest.env ?? {})) if (typeof value === 'string' && under(value)) manifest.env[name] = `${local}${value.slice(at.length)}`;
|
|
74
|
+
return at;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every served twin of a World manifest bridged (`volter-world attach`, a command in any language): its listener up, and
|
|
78
|
+
* the manifest pointed at it. Resolves to the listeners, unref'd. */
|
|
79
|
+
async function bridgeTwins(manifest, key, ca = undefined) {
|
|
80
|
+
const servers = [];
|
|
81
|
+
for (const [vendor, origin] of Object.entries(manifest.vendors ?? {})) {
|
|
82
|
+
if (!key || !/^https?:\/\//.test(String(origin))) continue;
|
|
83
|
+
const secret = bridgeSecret();
|
|
84
|
+
const server = await bridgeHttp(String(origin), key, 0, secret, ca);
|
|
85
|
+
server.unref();
|
|
86
|
+
pointAt(manifest, vendor, `http://127.0.0.1:${server.address().port}/${secret}`);
|
|
87
|
+
servers.push(server);
|
|
88
|
+
}
|
|
89
|
+
return servers;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { bridgeHttp, bridgeSecret, bridgeTwins, pointAt };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.13",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"inject.cjs",
|
|
25
25
|
"attach.cjs",
|
|
26
26
|
"stream-bridge.cjs",
|
|
27
|
+
"http-bridge.cjs",
|
|
27
28
|
"network-policy.cjs",
|
|
28
29
|
"network-policy.d.cts",
|
|
29
30
|
"app-route.cjs",
|
|
@@ -75,6 +76,7 @@
|
|
|
75
76
|
"./inject": "./inject.cjs",
|
|
76
77
|
"./attach": "./attach.cjs",
|
|
77
78
|
"./stream-bridge": "./stream-bridge.cjs",
|
|
79
|
+
"./http-bridge": "./http-bridge.cjs",
|
|
78
80
|
"./network-policy": {
|
|
79
81
|
"types": "./network-policy.d.cts",
|
|
80
82
|
"default": "./network-policy.cjs"
|
package/src/derived-real.ts
CHANGED
|
@@ -280,6 +280,9 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
280
280
|
return async (execute, opts) => {
|
|
281
281
|
const observed: ObservedResource[] = [];
|
|
282
282
|
const complete: Completed[] = [];
|
|
283
|
+
// what the root's credential may not read (a token scoped to some of the account): left as the World holds it, said
|
|
284
|
+
const unread: Array<{ type: string; path: string; status: number }> = [];
|
|
285
|
+
let answered = 0;
|
|
283
286
|
for (const unit of units) {
|
|
284
287
|
const { manifest: m, surface } = unit;
|
|
285
288
|
// parents before children, however deep
|
|
@@ -298,14 +301,20 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
298
301
|
const limit = m.list?.limit && (op.query ?? []).some((q) => q.name === m.list!.limit.param) ? { [m.list.limit.param]: String(m.list.limit.max) } : {};
|
|
299
302
|
let page: Record<string, string> = { ...limit };
|
|
300
303
|
for (let taken = 0; ;) {
|
|
301
|
-
|
|
304
|
+
let unfilled = false;
|
|
305
|
+
const path = `${op.basePath ?? surface.basePath ?? ''}${op.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => { const v = n === decl.parent?.param && parent !== undefined ? String(parent) : ''; if (!v) unfilled = true; return encodeURIComponent(v); });
|
|
302
306
|
// a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
|
|
303
307
|
const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) } : {};
|
|
304
308
|
const q = new URLSearchParams({ ...page, ...byQuery });
|
|
305
309
|
const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path, ...(unit.lane ? { lane: unit.lane } : {}) });
|
|
306
|
-
if (answer.status === 401
|
|
310
|
+
if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
311
|
+
if (answer.status === 403) { unread.push({ type, path, status: 403 }); whole = false; break; }
|
|
307
312
|
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
313
|
+
// one subject read by its parent and not there (an account with no workers.dev subdomain): nothing to observe. A
|
|
314
|
+
// path missing a parameter (an empty segment) is the kernel's mistake, never the vendor's absence
|
|
315
|
+
if ('get' in scope && answer.status === 404 && !unfilled) { answered += 1; break; }
|
|
308
316
|
if (answer.status >= 400) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
317
|
+
answered += 1;
|
|
309
318
|
const parsed = parse(answer);
|
|
310
319
|
const items = 'get' in scope ? [object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {}]
|
|
311
320
|
: 'items' in scope && scope.items ? ((at(parsed, scope.items) as unknown[] | undefined) ?? []).map((x) => object(x)).filter((x): x is Row => x !== undefined) : itemsOf(op, parsed);
|
|
@@ -344,8 +353,11 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
344
353
|
else if (whole && parents.length) complete.push({ type, field: decl.parent!.field, within: parents.map(String) });
|
|
345
354
|
}
|
|
346
355
|
}
|
|
356
|
+
// every read refused is a credential the vendor refuses, never an account that holds nothing
|
|
357
|
+
if (unread.length && !answered) throw new Error(`403: the vendor refused the root's credential for every read (${unread.map((u) => u.path).join(', ')}); nothing folded`);
|
|
347
358
|
adopt(service, opts.root, units, observed);
|
|
348
|
-
|
|
359
|
+
const report = observeResources(service, observed, { ...(opts.root !== undefined ? { root: opts.root } : {}), complete });
|
|
360
|
+
return unread.length ? { ...(report as object), unread } : report;
|
|
349
361
|
};
|
|
350
362
|
}
|
|
351
363
|
|
package/src/pack-fetch.ts
CHANGED
|
@@ -124,10 +124,15 @@ const DECODE = { gzip: gunzipSync, zstd: zstdDecompressSync, deflate: inflateSyn
|
|
|
124
124
|
/** A request with its body decoded as its Content-Encoding says, when the manifest's `encodings` reads that coding;
|
|
125
125
|
* undefined when the bytes are not what the header names. */
|
|
126
126
|
async function decoded(m: DerivedManifest, request: Request): Promise<Request | undefined> {
|
|
127
|
+
if (request.method === 'GET' || request.method === 'HEAD' || request.body === null) return request;
|
|
128
|
+
// the body read once, here, and every part of the pack given a request made again from its bytes: a served request's
|
|
129
|
+
// body read through a clone and then read again (an `around` that checks a body before its handler does) tees a stream
|
|
130
|
+
// whose second branch a Bun server never finishes, and the request hung
|
|
131
|
+
const raw = new Uint8Array(await request.arrayBuffer());
|
|
127
132
|
const coding = (request.headers.get('content-encoding') ?? '').trim().toLowerCase() as keyof typeof DECODE;
|
|
128
|
-
if (!m.encodings?.request?.includes(coding)
|
|
133
|
+
if (!m.encodings?.request?.includes(coding)) return new Request(request.url, { method: request.method, headers: request.headers, body: raw });
|
|
129
134
|
let body: Uint8Array;
|
|
130
|
-
try { body = DECODE[coding](
|
|
135
|
+
try { body = DECODE[coding](raw); } catch { return undefined; }
|
|
131
136
|
const headers = new Headers(request.headers);
|
|
132
137
|
headers.delete('content-encoding');
|
|
133
138
|
headers.delete('content-length');
|
|
@@ -249,10 +254,15 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
249
254
|
const workspaces = (): Workspace[] => [...(m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path })), ...lanesWorkspaces(parts.lanes ?? {})];
|
|
250
255
|
/** Whether a built screen a person views (a workspace or a flow, never content an API client reads) draws a url: its host
|
|
251
256
|
* one the screen names, its path the screen's or under it (a root screen draws its root alone). */
|
|
257
|
+
// by what the pack declares built (a screen drawn by another's handler, as X's profile is by its web pages, is built)
|
|
258
|
+
const viewed = (m.screens ?? []).filter((s) => s.status === 'done' && s.kind !== 'content').map((s) => {
|
|
259
|
+
const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
|
|
260
|
+
return { root: s.path.replace(/\/+$/, '') === '', host: (h: string) => hosts.some((match) => match(h)), path: pathMatcher(s.path, true) };
|
|
261
|
+
});
|
|
252
262
|
const drawsUrl = (at: string): boolean => {
|
|
253
263
|
try {
|
|
254
264
|
const u = new URL(at); const path = u.pathname.replace(/\/+$/, '') || '/';
|
|
255
|
-
return
|
|
265
|
+
return viewed.some((s) => s.host(u.hostname.toLowerCase()) && (s.root ? path === '/' : Boolean(s.path(path))));
|
|
256
266
|
} catch { return false; }
|
|
257
267
|
};
|
|
258
268
|
// a context the kernel opens for what no surface operation names: a door, a screen, the clock, the board
|
|
@@ -277,7 +287,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
277
287
|
if (path === url.pathname && !Object.keys(groups).length) return request;
|
|
278
288
|
// the path the client sent, which a signature over the request (SigV4's canonical URI) covers
|
|
279
289
|
const headers = new Headers(request.headers);
|
|
280
|
-
|
|
290
|
+
// the path as first sent: a front before this (an attach bridge) may have named the one its client signed
|
|
291
|
+
if (!headers.has(ORIGINAL_PATH_HEADER)) headers.set(ORIGINAL_PATH_HEADER, url.pathname);
|
|
281
292
|
if (Object.keys(groups).length) headers.set(PREFIX_PARAMS_HEADER, JSON.stringify(groups));
|
|
282
293
|
url.pathname = path;
|
|
283
294
|
return new Request(url, { method: request.method, headers, body: request.body, ...(request.body ? { duplex: 'half' } : {}) } as RequestInit);
|
package/src/runtime.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrit
|
|
|
12
12
|
export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.ts';
|
|
13
13
|
export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
|
|
14
14
|
export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
|
|
15
|
+
export { ORIGINAL_PATH_HEADER } from './sigv4.ts';
|
|
15
16
|
export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.ts';
|
|
16
17
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
|
|
17
18
|
export { compileSurface, createDerivedFetch, matchOperation } from './derived.ts';
|