@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
package/inject.cjs
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// @volter/world-core/inject — zero-edit backend injection.
|
|
3
|
+
//
|
|
4
|
+
// Redirect a process's calls to real vendor hosts (api.stripe.com, slack.com,
|
|
5
|
+
// api.github.com, *.atlassian.net, api.linear.app, S3 endpoints, and other
|
|
6
|
+
// configured vendor API hosts) to local twins, WITHOUT
|
|
7
|
+
// changing the app: the app constructs its SDK exactly as in production.
|
|
8
|
+
//
|
|
9
|
+
// node --require @volter/world-core/inject app.js
|
|
10
|
+
// NODE_OPTIONS="--require @volter/world-core/inject" <your start command>
|
|
11
|
+
//
|
|
12
|
+
// <VENDOR>_TWIN_URL for every pack in the catalog (uppercase vendor id, non-alnum → _),
|
|
13
|
+
// e.g. STRIPE_TWIN_URL=http://127.0.0.1:12111 or a hosted skin URL
|
|
14
|
+
// {twinsUrl}/{org}/{world}/{vendor} with VOLTER_TWINS_KEY set (R7c). Host claims come
|
|
15
|
+
// from pack-facts at runtime — this file carries no per-vendor roster to rot.
|
|
16
|
+
// AWS service-areas (DYNAMODB/TIMESTREAM/SESV2/SECRETSMANAGER/BEDROCK_TWIN_URL) point at
|
|
17
|
+
// the consolidated `aws` twin, same as S3_TWIN_URL.
|
|
18
|
+
// …or combined: TWIN_INJECT="stripe=http://127.0.0.1:12111,github=http://127.0.0.1:12120"
|
|
19
|
+
//
|
|
20
|
+
// It patches http/https `request`/`get` and global `fetch` at the process
|
|
21
|
+
// boundary. Every Node vendor SDK in this set routes through one of those
|
|
22
|
+
// (stripe-node → https; @octokit → fetch; @slack/web-api & jira.js → axios →
|
|
23
|
+
// http/https; @linear/sdk → fetch), so no per-SDK shim is needed.
|
|
24
|
+
//
|
|
25
|
+
// A client can still open its own sockets (npm `undici`'s Agent, which
|
|
26
|
+
// @turbopuffer/turbopuffer uses under Node, honours neither patch nor HTTPS_PROXY).
|
|
27
|
+
// `net.connect`/`tls.connect` are the seam every Node client ends at, so they carry
|
|
28
|
+
// the backstop: a connect to a twinned vendor host went around the rewrite and is
|
|
29
|
+
// refused, never sent to the real vendor; a connect to an untwinned host is refused
|
|
30
|
+
// wherever strict egress or the World's network policy refuses it. The refusal
|
|
31
|
+
// names the pack's endpoint env, the SDK's own door to the twin, when it has one.
|
|
32
|
+
const http = require('http');
|
|
33
|
+
const https = require('https');
|
|
34
|
+
const net = require('net');
|
|
35
|
+
const tls = require('tls');
|
|
36
|
+
const { isIP } = require('node:net');
|
|
37
|
+
const { EventEmitter } = require('events');
|
|
38
|
+
const { Readable } = require('stream');
|
|
39
|
+
const Module = require('module');
|
|
40
|
+
const { worldNetworkPolicyFromEnv, isWorldInternalHost, worldEgressRefusal } = require('./network-policy.cjs');
|
|
41
|
+
const { createPublicKey, createVerify } = require('crypto');
|
|
42
|
+
|
|
43
|
+
// the vendor host table (VENDOR_HOSTS: a hostname, and a path where two vendors share a host, to the vendor whose twin
|
|
44
|
+
// serves it) has its own home, shared with the application route
|
|
45
|
+
const { VENDOR_HOSTS, isVendorHost } = require('./vendor-hosts.cjs');
|
|
46
|
+
const dns = require('dns');
|
|
47
|
+
|
|
48
|
+
function decodeBase64UrlJson(part) {
|
|
49
|
+
try {
|
|
50
|
+
const padded = String(part).replace(/-/g, '+').replace(/_/g, '/');
|
|
51
|
+
const json = Buffer.from(padded + '='.repeat((4 - (padded.length % 4)) % 4), 'base64').toString('utf8');
|
|
52
|
+
return JSON.parse(json);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function decodeBase64Url(part) {
|
|
59
|
+
const padded = String(part || '').replace(/-/g, '+').replace(/_/g, '/');
|
|
60
|
+
return Buffer.from(padded + '='.repeat((4 - (padded.length % 4)) % 4), 'base64');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function verifyJwtWithCachedJwks(token, jwks) {
|
|
64
|
+
const parts = String(token || '').split('.');
|
|
65
|
+
if (parts.length !== 3 || !jwks || !Array.isArray(jwks.keys)) return null;
|
|
66
|
+
const header = decodeBase64UrlJson(parts[0]);
|
|
67
|
+
const payload = decodeBase64UrlJson(parts[1]);
|
|
68
|
+
if (!header || !payload || header.alg !== 'RS256') return null;
|
|
69
|
+
const key = jwks.keys.find((candidate) => candidate && candidate.kid === header.kid) || jwks.keys[0];
|
|
70
|
+
if (!key) return null;
|
|
71
|
+
try {
|
|
72
|
+
const verifier = createVerify('RSA-SHA256');
|
|
73
|
+
verifier.update(`${parts[0]}.${parts[1]}`);
|
|
74
|
+
verifier.end();
|
|
75
|
+
const publicKey = createPublicKey({ key: { kty: 'RSA', n: key.n, e: key.e }, format: 'jwk' });
|
|
76
|
+
if (!verifier.verify(publicKey, decodeBase64Url(parts[2]))) return null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
if (typeof payload.exp === 'number' && now >= payload.exp) return null;
|
|
82
|
+
if (typeof payload.nbf === 'number' && now < payload.nbf) return null;
|
|
83
|
+
const issuer = String(payload.iss || '');
|
|
84
|
+
const isTwinIssuer = issuer.includes('twin.clerk') || issuer.includes('clerk.twin') || issuer.includes('accounts.dev');
|
|
85
|
+
return isTwinIssuer ? payload : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function installClerkFastifyTwinShim() {
|
|
89
|
+
if (!process.env.CLERK_TWIN_URL || Module.__volterClerkFastifyShimInstalled) return;
|
|
90
|
+
Module.__volterClerkFastifyShimInstalled = true;
|
|
91
|
+
let cachedJwks = null;
|
|
92
|
+
const originalLoad = Module._load;
|
|
93
|
+
Module._load = function volterTwinLoad(request, parent, isMain) {
|
|
94
|
+
if (request === '@clerk/fastify') {
|
|
95
|
+
return {
|
|
96
|
+
clerkPlugin: async function clerkPlugin() {
|
|
97
|
+
const baseUrl = String(process.env.CLERK_TWIN_URL || '').replace(/\/$/, '');
|
|
98
|
+
if (!baseUrl) return;
|
|
99
|
+
const response = await fetch(`${baseUrl}/.well-known/jwks.json`);
|
|
100
|
+
if (!response.ok) throw new Error(`Clerk twin JWKS fetch failed: ${response.status}`);
|
|
101
|
+
cachedJwks = await response.json();
|
|
102
|
+
},
|
|
103
|
+
getAuth: function getAuth(req) {
|
|
104
|
+
const auth = req && (req.headers?.authorization || req.headers?.Authorization);
|
|
105
|
+
const token = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : '';
|
|
106
|
+
const payload = token ? verifyJwtWithCachedJwks(token, cachedJwks) : null;
|
|
107
|
+
return {
|
|
108
|
+
userId: typeof payload?.sub === 'string' ? payload.sub : null,
|
|
109
|
+
sessionId: typeof payload?.sid === 'string' ? payload.sid : null,
|
|
110
|
+
orgId: typeof payload?.org_id === 'string' ? payload.org_id : null,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return originalLoad.apply(this, arguments);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
installClerkFastifyTwinShim();
|
|
120
|
+
|
|
121
|
+
// Prisma over a twinned database. Prisma's client-engine build — the build a tab runs, and
|
|
122
|
+
// the one its edge client uses — refuses to construct without a driver adapter, and an
|
|
123
|
+
// application written against Prisma's library engine names none (`new PrismaClient()`).
|
|
124
|
+
// When the attached World twins a database vendor whose pack declares `prismaAdapter`
|
|
125
|
+
// (DATA on the descriptor, compiled into pack-facts), a client constructed without an
|
|
126
|
+
// adapter gets that vendor's HTTP adapter from the APPLICATION'S OWN dependencies,
|
|
127
|
+
// resolved from the requiring module, over the URL the endpoint template rendered into
|
|
128
|
+
// the vendor's `urlEnv`. An application that already passes an adapter, or does not
|
|
129
|
+
// install the adapter package, is left exactly as it was. The vendor is chosen by the
|
|
130
|
+
// twin that is attached AND whose URL variable is set; the first such pack wins.
|
|
131
|
+
function installPrismaAdapterShim() {
|
|
132
|
+
if (Module.__volterPrismaAdapterShimInstalled) return;
|
|
133
|
+
const { packs } = require('./generated/pack-facts.json');
|
|
134
|
+
const candidates = Object.keys(packs).filter((vendor) => packs[vendor].prismaAdapter).sort();
|
|
135
|
+
if (candidates.length === 0) return;
|
|
136
|
+
Module.__volterPrismaAdapterShimInstalled = true;
|
|
137
|
+
const path = require('path');
|
|
138
|
+
const originalLoad = Module._load;
|
|
139
|
+
Module._load = function volterPrismaLoad(request, parent, isMain) {
|
|
140
|
+
const exports = originalLoad.apply(this, arguments);
|
|
141
|
+
if (request !== '@prisma/client' || !exports || typeof exports.PrismaClient !== 'function' || exports.PrismaClient.__volterAdapted) return exports;
|
|
142
|
+
const vendor = candidates.find((candidate) => MAP[candidate] && process.env[packs[candidate].prismaAdapter.urlEnv]);
|
|
143
|
+
if (!vendor) return exports;
|
|
144
|
+
const facts = packs[vendor].prismaAdapter;
|
|
145
|
+
// From the requiring module first (a library that constructs the client), then the
|
|
146
|
+
// application itself, whose dependency the adapter is under pnpm's isolated layout.
|
|
147
|
+
const from = parent && parent.filename ? path.dirname(parent.filename) : process.cwd();
|
|
148
|
+
let adapterModule;
|
|
149
|
+
try {
|
|
150
|
+
adapterModule = originalLoad.call(this, require.resolve(facts.adapter, { paths: [from, process.cwd()] }), parent, false);
|
|
151
|
+
} catch {
|
|
152
|
+
// Left as it was, said once: on the client engine Prisma refuses the construction itself;
|
|
153
|
+
// on the library engine the client reaches the twin over the vendor's own wire.
|
|
154
|
+
process.stderr.write(`[twin-inject] ${vendor}: ${facts.adapter} is not installed in this application, so a Prisma client constructed without an adapter is left as it was\n`);
|
|
155
|
+
exports.PrismaClient.__volterAdapted = true;
|
|
156
|
+
return exports;
|
|
157
|
+
}
|
|
158
|
+
const Adapter = adapterModule && adapterModule[facts.export];
|
|
159
|
+
if (typeof Adapter !== 'function') {
|
|
160
|
+
process.stderr.write(`[twin-inject] ${vendor}: ${facts.adapter} exports no ${facts.export}; a Prisma client constructed without an adapter is left as it was\n`);
|
|
161
|
+
exports.PrismaClient.__volterAdapted = true;
|
|
162
|
+
return exports;
|
|
163
|
+
}
|
|
164
|
+
const Base = exports.PrismaClient;
|
|
165
|
+
class PrismaClient extends Base {
|
|
166
|
+
constructor(options) {
|
|
167
|
+
const given = options || {};
|
|
168
|
+
// An adapter the application passes, or `null`, Prisma's own explicit opt-out, is its
|
|
169
|
+
// decision. The URL is the application's where it names one (its host is twinned by the
|
|
170
|
+
// injector's own host rules), else the endpoint template's variable.
|
|
171
|
+
if ('adapter' in given) { super(given); return; }
|
|
172
|
+
const named = given.datasources && typeof given.datasources === 'object'
|
|
173
|
+
? Object.values(given.datasources).map((source) => source && source.url).find((value) => typeof value === 'string') : undefined;
|
|
174
|
+
const url = given.datasourceUrl || named || process.env[facts.urlEnv];
|
|
175
|
+
super({ ...given, adapter: new Adapter({ url, fetch: (...args) => globalThis.fetch(...args) }) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
PrismaClient.__volterAdapted = true;
|
|
179
|
+
exports.PrismaClient = PrismaClient;
|
|
180
|
+
return exports;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The exact inverse of readMap's env lookup: which vendor (if any) would the injector read
|
|
185
|
+
// this env var for? `S3_TWIN_URL` → 's3'; `AWS_TWIN_URL` → null (the consolidated aws twin
|
|
186
|
+
// answers as s3/dynamodb/timestream/sesv2/secretsmanager/bedrock — there IS no 'aws' vendor key, which is exactly the
|
|
187
|
+
// silent-inert-env trap this helper exists to make checkable). Shared with world-runtime so
|
|
188
|
+
// its "this injectEnv var is inert" warning can never drift from what readMap actually reads.
|
|
189
|
+
function twinEnvStem(vendor) {
|
|
190
|
+
return String(vendor).toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function twinUrlVendor(name) {
|
|
194
|
+
const match = /^([A-Z][A-Z0-9_]*)_TWIN_URL$/.exec(String(name || ''));
|
|
195
|
+
if (!match) return null;
|
|
196
|
+
return Object.keys(VENDOR_HOSTS).find((vendor) => twinEnvStem(vendor) === match[1]) || null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readMap(env) {
|
|
200
|
+
const map = {};
|
|
201
|
+
// the World instance whose recorded app hosts this map routes (APP HOSTS, below); not a vendor, so not a key
|
|
202
|
+
if (env.VOLTER_WORLD_INSTANCE) Object.defineProperty(map, APP_INSTANCE, { value: env.VOLTER_WORLD_INSTANCE, enumerable: false });
|
|
203
|
+
for (const vendor of Object.keys(VENDOR_HOSTS)) {
|
|
204
|
+
const url = env[`${twinEnvStem(vendor)}_TWIN_URL`];
|
|
205
|
+
if (url) map[vendor] = url.replace(/\/$/, '');
|
|
206
|
+
}
|
|
207
|
+
if (env.TWIN_INJECT) {
|
|
208
|
+
for (const pair of env.TWIN_INJECT.split(',')) {
|
|
209
|
+
const eq = pair.indexOf('=');
|
|
210
|
+
if (eq <= 0) continue;
|
|
211
|
+
const vendor = pair.slice(0, eq).trim();
|
|
212
|
+
const url = pair.slice(eq + 1).trim();
|
|
213
|
+
if (vendor && url && VENDOR_HOSTS[vendor]) map[vendor] = url.replace(/\/$/, '');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return map;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Which twin (if any) serves `hostname` — and, when the caller knows it, `pathname`.
|
|
221
|
+
*
|
|
222
|
+
* `pathname` is optional and backward-compatible: predicates that take one argument ignore it, so
|
|
223
|
+
* a two-argument call is identical to the old behavior for every vendor that owns its host. It
|
|
224
|
+
* matters only where two vendors SHARE a host (see the VENDOR_HOSTS header). Callers that have a
|
|
225
|
+
* URL should always pass it; callers that genuinely do not (the proxy's CONNECT handler, which
|
|
226
|
+
* sees only the SNI name) omit it and get host-level candidacy.
|
|
227
|
+
*/
|
|
228
|
+
function resolveTwin(hostname, map, pathname) {
|
|
229
|
+
for (const vendor of Object.keys(VENDOR_HOSTS)) {
|
|
230
|
+
if (map[vendor] && VENDOR_HOSTS[vendor](hostname, pathname)) return { vendor, origin: map[vendor] };
|
|
231
|
+
}
|
|
232
|
+
// then a host a running twin claims (a custom domain a person connected), as DNS would route it; never one a
|
|
233
|
+
// descriptor's hosts name, which the loop above has already answered
|
|
234
|
+
const host = String(hostname || '').toLowerCase();
|
|
235
|
+
for (const vendor of Object.keys(CLAIMERS)) {
|
|
236
|
+
if (map[vendor] && claimsAt(map[vendor], CLAIMERS[vendor]).hosts.has(host)) return { vendor, origin: map[vendor] };
|
|
237
|
+
}
|
|
238
|
+
// last, the application's own production hostnames, routed to where it listens in the World; never the World's own
|
|
239
|
+
// origin or a twin's, whose requests carry the World key
|
|
240
|
+
const app = appRoute(map[APP_INSTANCE]);
|
|
241
|
+
const bare = host.replace(/\.$/, '');
|
|
242
|
+
const base = worldBase();
|
|
243
|
+
// a name a vendor's host rule gives its twin is never the application's, whether or not that twin runs here
|
|
244
|
+
if (app && appHostMatch(app.hosts, bare) && !isVendorHost(bare) && !isTwinOriginHost(bare) && (!base || new URL(base.origin).hostname !== bare)) {
|
|
245
|
+
return { vendor: 'app', origin: app.origin };
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// APP HOSTS: the hostnames an application answers as in production (`volter-world app-url <world> --host`), recorded
|
|
251
|
+
// beside its URL in the instance (`app-url.json`), and routed to that URL as DNS routes them to its host in production.
|
|
252
|
+
// Read from the instance the World env names, at most once a second, so a host or URL recorded after the process
|
|
253
|
+
// started applies. The request keeps its Host and says, as a proxy in front of it would, the host and the scheme it
|
|
254
|
+
// was made with (appForwardHeaders), so an application serving several hostnames answers each as in production.
|
|
255
|
+
const APP_INSTANCE = Symbol.for('volter.inject.appInstance');
|
|
256
|
+
// the route itself (the record, its wildcards, the forwarded headers) has one home, shared with the twins' deliveries
|
|
257
|
+
const { appRoute, appHostMatch, appForwardHeaders, appRequestOptions, sessionCa } = require('./app-route.cjs');
|
|
258
|
+
|
|
259
|
+
// HOSTS A TWIN CLAIMS WHILE IT RUNS (packRegistry `hostsClaimed`): no descriptor can list a name a person makes answer
|
|
260
|
+
// to a vendor, so each claiming twin lists its own at its door, read here every five seconds from the moment the
|
|
261
|
+
// injector installs (or, in a process that only resolves, the proxy, from its first resolve). A fetch that would be
|
|
262
|
+
// refused while a first read is still in flight waits for it; a name connected moments ago is refused until the next
|
|
263
|
+
// read, as a new DNS record takes a moment.
|
|
264
|
+
const CLAIMERS = {};
|
|
265
|
+
{
|
|
266
|
+
const { packs } = require('./generated/pack-facts.json');
|
|
267
|
+
for (const vendor of Object.keys(packs)) if (packs[vendor].hostsClaimed) CLAIMERS[vendor] = packs[vendor].hostsClaimed.door;
|
|
268
|
+
}
|
|
269
|
+
const CLAIMS = new Map(); // cache: origin -> the hosts its twin last listed; refreshed by the poll below
|
|
270
|
+
function claimsAt(origin, door) {
|
|
271
|
+
const held = CLAIMS.get(origin);
|
|
272
|
+
if (held) return held;
|
|
273
|
+
const entry = { hosts: new Set(), loaded: false, ready: null };
|
|
274
|
+
let settle;
|
|
275
|
+
entry.ready = new Promise((resolve) => { settle = resolve; });
|
|
276
|
+
CLAIMS.set(origin, entry);
|
|
277
|
+
const read = () => {
|
|
278
|
+
// the unpatched fetch: the door is the twin's own origin, never a vendor host to redirect
|
|
279
|
+
const f = (ORIGINALS && ORIGINALS.fetch) || globalThis.fetch;
|
|
280
|
+
if (typeof f !== 'function') return;
|
|
281
|
+
Promise.resolve()
|
|
282
|
+
.then(() => f(`${origin}${door}`, { headers: attachTwinsKey({}) }))
|
|
283
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
284
|
+
.then((body) => { if (body && Array.isArray(body.hosts)) entry.hosts = new Set(body.hosts.map((h) => String(h).toLowerCase())); })
|
|
285
|
+
.catch(() => {})
|
|
286
|
+
.then(() => { entry.loaded = true; settle(); });
|
|
287
|
+
};
|
|
288
|
+
read();
|
|
289
|
+
// every five seconds: each process that loads the injector reads (the app's workers, the proxy), and a custom domain
|
|
290
|
+
// takes Cloudflare minutes to turn Active, so a faster read buys nothing and loads the twin
|
|
291
|
+
const timer = setInterval(read, 5000);
|
|
292
|
+
if (timer.unref) timer.unref();
|
|
293
|
+
return entry;
|
|
294
|
+
}
|
|
295
|
+
/** The first read of a claiming twin's door still in flight for this map, or null when every one has landed. */
|
|
296
|
+
function claimsPending(map) {
|
|
297
|
+
const pending = Object.keys(CLAIMERS).filter((v) => map[v]).map((v) => claimsAt(map[v], CLAIMERS[v])).filter((e) => !e.loaded);
|
|
298
|
+
return pending.length ? Promise.all(pending.map((e) => e.ready)) : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isLocalOrPrivateHost(hostname) {
|
|
302
|
+
return isWorldInternalHost(hostname);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function strictEgressEnabled() {
|
|
306
|
+
return process.env.VOLTER_TWIN_STRICT_EGRESS === '1' || process.env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function shouldBlockUntwinned(url) {
|
|
310
|
+
const routed = url && resolveTwin(url.hostname, MAP, url.pathname);
|
|
311
|
+
if (!url || (routed && routed.vendor !== 'app')) return false;
|
|
312
|
+
return worldEgressRefusal(url) !== null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- untwinned-host TLS failure naming (local mode) -----------------------------------------
|
|
316
|
+
// Untwinned local-mode requests reach their real origin. A certificate error still needs
|
|
317
|
+
// the routing context, but session trust supplements public roots; it is not the cause
|
|
318
|
+
// of every failed certificate chain.
|
|
319
|
+
const TLS_ISSUER_CODES = new Set([
|
|
320
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
321
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
322
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
323
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
324
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
// Only annotate when the world's CA env is actually in force — a TLS failure without it is a
|
|
328
|
+
// genuinely bad chain, and blaming the world would mislead. VOLTER_WORLD_CA is the world's
|
|
329
|
+
// definitive marker (proxyEnvFor sets it alongside the trust vars); SSL_CERT_FILE is the var
|
|
330
|
+
// that mechanically replaces Node's root store.
|
|
331
|
+
function worldCaIsConfigured() {
|
|
332
|
+
return Boolean(process.env.VOLTER_WORLD_CA || process.env.SSL_CERT_FILE);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function untwinnedTlsExplanation(hostname) {
|
|
336
|
+
return `[twin-inject] host ${hostname} is not twinned in this world; local mode does not intercept it — this traffic goes to the REAL vendor. (The real origin's certificate failed verification. Check its certificate chain and configured trust. Use sealed mode for a strict refusal, or add a twin for ${hostname}.)`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function annotateUntwinnedTlsError(error, hostname) {
|
|
340
|
+
let cursor = error;
|
|
341
|
+
for (let depth = 0; cursor && depth < 5; depth += 1) {
|
|
342
|
+
const code = cursor.code;
|
|
343
|
+
const message = String(cursor.message || '');
|
|
344
|
+
if ((code && TLS_ISSUER_CODES.has(code)) || message.includes('unable to get local issuer certificate')) {
|
|
345
|
+
if (error instanceof Error && !String(error.message).includes('[twin-inject]')) {
|
|
346
|
+
error.message = `${error.message}\n${untwinnedTlsExplanation(hostname)}`;
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
cursor = cursor.cause;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- request-args → { url, method, headers, callback } across every overload ---
|
|
355
|
+
function describe(args, defaultProtocol) {
|
|
356
|
+
const input = args[0];
|
|
357
|
+
let options = {};
|
|
358
|
+
let callback;
|
|
359
|
+
if (typeof args[1] === 'function') callback = args[1];
|
|
360
|
+
else {
|
|
361
|
+
options = args[1] || {};
|
|
362
|
+
if (typeof args[2] === 'function') callback = args[2];
|
|
363
|
+
}
|
|
364
|
+
let url = null;
|
|
365
|
+
let headers = {};
|
|
366
|
+
let method = 'GET';
|
|
367
|
+
if (input instanceof URL || typeof input === 'string') {
|
|
368
|
+
try { url = new URL(String(input)); } catch { url = null; }
|
|
369
|
+
headers = options.headers || {};
|
|
370
|
+
method = options.method || 'GET';
|
|
371
|
+
} else {
|
|
372
|
+
const merged = Object.assign({}, input || {}, options);
|
|
373
|
+
headers = merged.headers || {};
|
|
374
|
+
method = merged.method || 'GET';
|
|
375
|
+
const proto = String(merged.protocol || defaultProtocol);
|
|
376
|
+
const host = merged.hostname || merged.host;
|
|
377
|
+
const path = merged.path || '/';
|
|
378
|
+
// A request addressed to a proxy (axios and others honour the World's HTTPS_PROXY) names the proxy as its host
|
|
379
|
+
// and carries the real target as its path: "When making a request to a proxy ... a client MUST send the target
|
|
380
|
+
// URI in "absolute-form" as the request-target" (RFC 9112 §3.2.2). That target is the request's URL.
|
|
381
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
|
|
382
|
+
try { url = new URL(path); } catch { url = null; }
|
|
383
|
+
} else if (host) {
|
|
384
|
+
try { url = new URL(`${proto.endsWith(':') ? proto : `${proto}:`}//${host}${path}`); } catch { url = null; }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { url, method: String(method).toUpperCase(), headers, callback };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let ORIGINALS = null; // captured once, used to forward to the twin without re-entering the patch
|
|
391
|
+
|
|
392
|
+
// R7c (runtime contract): when *_TWIN_URL points at a hosted SKIN
|
|
393
|
+
// ({twinsUrl}/{org}/{world}/{vendor}), the transport key rides x-twins-key on every
|
|
394
|
+
// REWRITTEN request — its own header, never Authorization (that stays vendor
|
|
395
|
+
// semantics). One key per world: VOLTER_TWINS_KEY. Absent = local loopback twins,
|
|
396
|
+
// no key attached — exactly today's behavior.
|
|
397
|
+
function twinsKey() { return process.env.VOLTER_TWINS_KEY || null; }
|
|
398
|
+
function attachTwinsKey(headers) {
|
|
399
|
+
const key = twinsKey();
|
|
400
|
+
// Case-insensitive caller check (S4 review F6): node header iteration is
|
|
401
|
+
// last-write-wins case-insensitively, so ANY caller casing must suppress the attach.
|
|
402
|
+
if (key && !Object.keys(headers).some((k) => k.toLowerCase() === 'x-twins-key')) headers['x-twins-key'] = key;
|
|
403
|
+
return headers;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The hosted World this process is attached to (VOLTER_WORLD, a URL). A request an SDK addresses
|
|
407
|
+
// to one of the World's own paths — an app-read endpoint (TUNNEL_SERVER_URL, QSTASH_URL) pointed
|
|
408
|
+
// at its twin, or its doors — is the World's wire as much as a rewritten one, so it carries the
|
|
409
|
+
// same key (R7c). Only there: never to any other origin.
|
|
410
|
+
function worldBase() {
|
|
411
|
+
const ref = process.env.VOLTER_WORLD;
|
|
412
|
+
if (!ref || !/^https?:\/\//i.test(ref)) return null;
|
|
413
|
+
try { const base = new URL(ref.replace(/\/\.well-known\/volter-world\/?$/, '').replace(/\/+$/, '')); return { origin: base.origin, path: base.pathname.replace(/\/+$/, '') }; } catch { return null; }
|
|
414
|
+
}
|
|
415
|
+
function addressedToWorld(url) {
|
|
416
|
+
const base = worldBase();
|
|
417
|
+
if (!base || !url || !base.path || url.origin.replace(/^ws/, 'http') !== base.origin) return false;
|
|
418
|
+
return url.pathname.startsWith(`${base.path}/`) || url.pathname.startsWith(`/-${base.path}/`);
|
|
419
|
+
}
|
|
420
|
+
/** http(s).request's arguments with the World key added to its headers (a copy; the caller's own
|
|
421
|
+
* header, in any casing, wins). */
|
|
422
|
+
function withWorldKey(args) {
|
|
423
|
+
const key = twinsKey();
|
|
424
|
+
if (!key) return args;
|
|
425
|
+
const keyed = (options) => {
|
|
426
|
+
const headers = Object.assign({}, (options && options.headers) || {});
|
|
427
|
+
if (!Object.keys(headers).some((k) => k.toLowerCase() === 'x-twins-key')) headers['x-twins-key'] = key;
|
|
428
|
+
return Object.assign({}, options || {}, { headers });
|
|
429
|
+
};
|
|
430
|
+
const out = args.slice();
|
|
431
|
+
if (typeof out[0] === 'string' || out[0] instanceof URL) {
|
|
432
|
+
if (out[1] && typeof out[1] === 'object') out[1] = keyed(out[1]);
|
|
433
|
+
else out.splice(1, 0, keyed({}));
|
|
434
|
+
} else {
|
|
435
|
+
out[0] = keyed(out[0]);
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// S4 review F3: undici's default redirect-follow forwards CUSTOM headers across origins — a
|
|
441
|
+
// twin (or the World) answering 30x to an external URL would carry x-twins-key to a REAL host. A
|
|
442
|
+
// keyed fetch follows redirects itself: a hop that is a twin or the World re-enters the patched
|
|
443
|
+
// fetch (the key re-attached there); any other hop is fetched WITHOUT the key.
|
|
444
|
+
async function followKeyless(first, target, reqInit) {
|
|
445
|
+
let hop = await first();
|
|
446
|
+
let hopUrl = target;
|
|
447
|
+
let hopMethod = String(reqInit.method || 'GET').toUpperCase();
|
|
448
|
+
let hopBody = reqInit.body;
|
|
449
|
+
for (let hops = 0; hops < 5; hops += 1) {
|
|
450
|
+
const location = hop.status >= 300 && hop.status < 400 ? hop.headers.get('location') : null;
|
|
451
|
+
if (!location) return hop;
|
|
452
|
+
// RFC semantics per hop (audit M7): 303 → GET; 301/302 demote POST to GET
|
|
453
|
+
// (the browser convention SDKs expect); 307/308 preserve method AND body.
|
|
454
|
+
if (hop.status === 303 || ((hop.status === 301 || hop.status === 302) && hopMethod === 'POST')) {
|
|
455
|
+
hopMethod = 'GET';
|
|
456
|
+
hopBody = undefined;
|
|
457
|
+
}
|
|
458
|
+
// Resolve against the CURRENT hop (a relative Location from hop 2 is relative to hop 2),
|
|
459
|
+
// cancel the interim body, and re-enter the PATCHED fetch with the ORIGINAL headers minus
|
|
460
|
+
// the key.
|
|
461
|
+
const nextUrl = new URL(location, hopUrl);
|
|
462
|
+
try { hop.body && (await hop.body.cancel()); } catch { /* interim body */ }
|
|
463
|
+
const hopHeaders = new Headers(reqInit.headers || {});
|
|
464
|
+
hopHeaders.delete('x-twins-key');
|
|
465
|
+
hopHeaders.delete('x-volter-twin-original-host');
|
|
466
|
+
hop = await globalThis.fetch(nextUrl, {
|
|
467
|
+
method: hopMethod,
|
|
468
|
+
headers: hopHeaders,
|
|
469
|
+
...(hopBody === undefined ? {} : { body: hopBody }),
|
|
470
|
+
redirect: 'manual',
|
|
471
|
+
});
|
|
472
|
+
hopUrl = nextUrl;
|
|
473
|
+
}
|
|
474
|
+
return hop;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// A request routed to the application is a real request to where it listens (the original http/https request, so
|
|
478
|
+
// streaming, aborts and timeouts behave as the client expects); its options are app-route.cjs's, shared with the twins'
|
|
479
|
+
// deliveries.
|
|
480
|
+
function requestApp(route, url, method, headers, extra, callback) {
|
|
481
|
+
const options = appRequestOptions(route, url, method, headers, extra);
|
|
482
|
+
const make = options.protocol === 'https:' ? ORIGINALS.httpsRequest : ORIGINALS.httpRequest;
|
|
483
|
+
return callback ? make(options, callback) : make(options);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// What leaves the application with a redirect to anywhere else: what native fetch drops crossing origins, and the World's own.
|
|
487
|
+
const CROSS_ORIGIN_DROPPED = ['authorization', 'cookie', 'proxy-authorization', 'x-twins-key', 'x-volter-twin-original-host'];
|
|
488
|
+
|
|
489
|
+
/** fetch() routed to the application, as native fetch behaves: the caller's redirect mode, its signal, a streamed
|
|
490
|
+
* body, decoded content; redirects followed (at most 20) within the application while they stay on its hosts, and
|
|
491
|
+
* through the patched fetch, without credentials or the World's headers, once they leave. */
|
|
492
|
+
async function fetchApp(route, url, input, init) {
|
|
493
|
+
const request = typeof input === 'object' && input !== null && !(input instanceof URL) && typeof input.url === 'string' ? input : null;
|
|
494
|
+
const mode = (init && init.redirect) || (request && request.redirect) || 'follow';
|
|
495
|
+
const signal = (init && init.signal) || (request && request.signal) || undefined;
|
|
496
|
+
let method = String((init && init.method) || (request && request.method) || 'GET').toUpperCase();
|
|
497
|
+
const headers = {};
|
|
498
|
+
new Headers((init && init.headers) || (request && request.headers) || {}).forEach((value, key) => { headers[key] = value; });
|
|
499
|
+
let body = init && init.body !== undefined ? init.body : request && request.body ? request.body : undefined;
|
|
500
|
+
if (body != null && typeof body !== 'string' && !Buffer.isBuffer(body)) {
|
|
501
|
+
// the body's own type (a form's boundary, URLSearchParams, a Blob's type), as native fetch sends it
|
|
502
|
+
const read = new Response(body);
|
|
503
|
+
const type = read.headers.get('content-type');
|
|
504
|
+
if (type && !Object.keys(headers).some((k) => k.toLowerCase() === 'content-type')) headers['content-type'] = type;
|
|
505
|
+
body = Buffer.from(await read.arrayBuffer());
|
|
506
|
+
} else if (typeof body === 'string' && !Object.keys(headers).some((k) => k.toLowerCase() === 'content-type')) headers['content-type'] = 'text/plain;charset=UTF-8';
|
|
507
|
+
let at = url; let current = route;
|
|
508
|
+
for (let hops = 0; ; hops++) {
|
|
509
|
+
if (signal && signal.aborted) throw signal.reason ?? new DOMException('This operation was aborted', 'AbortError');
|
|
510
|
+
const res = await new Promise((resolve, reject) => {
|
|
511
|
+
const req = requestApp(current, at, method, headers, { signal }, resolve);
|
|
512
|
+
req.on('error', reject);
|
|
513
|
+
if (body != null) req.write(body);
|
|
514
|
+
req.end();
|
|
515
|
+
});
|
|
516
|
+
const location = res.headers.location;
|
|
517
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && location && [301, 302, 303, 307, 308].includes(res.statusCode) && mode !== 'manual') {
|
|
518
|
+
res.resume();
|
|
519
|
+
if (mode === 'error') throw new TypeError('fetch failed: unexpected redirect');
|
|
520
|
+
if (hops >= 20) throw new TypeError('fetch failed: redirect count exceeded');
|
|
521
|
+
const next = new URL(String(location), at);
|
|
522
|
+
// 303 answers with a GET (a HEAD stays one); 301 and 302 turn only a POST into a GET, as fetch does
|
|
523
|
+
if ((res.statusCode === 303 && method !== 'HEAD') || ((res.statusCode === 301 || res.statusCode === 302) && method === 'POST')) {
|
|
524
|
+
method = 'GET'; body = undefined;
|
|
525
|
+
for (const k of Object.keys(headers)) if (/^content-/i.test(k)) delete headers[k];
|
|
526
|
+
}
|
|
527
|
+
const nextRoute = resolveTwin(next.hostname, MAP, next.pathname);
|
|
528
|
+
if (!nextRoute || nextRoute.vendor !== 'app') {
|
|
529
|
+
for (const k of Object.keys(headers)) if (CROSS_ORIGIN_DROPPED.includes(k.toLowerCase())) delete headers[k];
|
|
530
|
+
const onward = await globalThis.fetch(next.toString(), { method, headers, redirect: mode, ...(signal ? { signal } : {}), ...(body != null ? { body } : {}) });
|
|
531
|
+
return onward;
|
|
532
|
+
}
|
|
533
|
+
at = next; current = nextRoute;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const out = new Headers();
|
|
537
|
+
for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) for (const one of Array.isArray(v) ? v : [v]) out.append(k, String(one));
|
|
538
|
+
let stream = res;
|
|
539
|
+
const encoding = String(res.headers['content-encoding'] || '').toLowerCase();
|
|
540
|
+
const zlib = require('zlib');
|
|
541
|
+
const decoder = encoding === 'gzip' || encoding === 'x-gzip' ? zlib.createGunzip() : encoding === 'deflate' ? zlib.createInflate() : encoding === 'br' ? zlib.createBrotliDecompress() : null;
|
|
542
|
+
if (decoder) { stream = res.pipe(decoder); out.delete('content-encoding'); out.delete('content-length'); }
|
|
543
|
+
const empty = [101, 204, 205, 304].includes(res.statusCode) || method === 'HEAD';
|
|
544
|
+
if (empty) res.resume();
|
|
545
|
+
const response = new Response(empty ? null : Readable.toWeb(stream), { status: res.statusCode, statusText: res.statusMessage, headers: out });
|
|
546
|
+
Object.defineProperty(response, 'url', { value: at.toString() });
|
|
547
|
+
if (hops > 0) Object.defineProperty(response, 'redirected', { value: true });
|
|
548
|
+
return response;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function forward(origin, method, fullPath, headers, body) {
|
|
553
|
+
return new Promise((resolve, reject) => {
|
|
554
|
+
let target;
|
|
555
|
+
try { target = new URL(origin); } catch (error) { reject(new Error(`bad twin URL "${origin}": ${error.message}`)); return; }
|
|
556
|
+
const mod = target.protocol === 'https:' ? ORIGINALS.httpsRequest : ORIGINALS.httpRequest;
|
|
557
|
+
const fwdHeaders = attachTwinsKey(Object.assign({}, headers));
|
|
558
|
+
delete fwdHeaders['accept-encoding']; delete fwdHeaders['Accept-Encoding'];
|
|
559
|
+
// A SKIN origin carries its namespace as a PATH (/{org}/{world}/{vendor}) — prefix
|
|
560
|
+
// it, or the hosted twin is addressed at the wrong root (R7c). Loopback origins have
|
|
561
|
+
// pathname '/', which prefixes to nothing: local behavior unchanged.
|
|
562
|
+
const originPath = target.pathname.replace(/\/$/, '');
|
|
563
|
+
fullPath = `${originPath}${fullPath}`;
|
|
564
|
+
// an https twin origin (a served world's door) verifies against the session
|
|
565
|
+
// CA even when NODE_EXTRA_CA_CERTS was set after process start (the rung-2
|
|
566
|
+
// dormant attach does exactly that)
|
|
567
|
+
const ca = target.protocol === 'https:' ? sessionCa() : null;
|
|
568
|
+
const req = mod({
|
|
569
|
+
protocol: target.protocol,
|
|
570
|
+
hostname: target.hostname,
|
|
571
|
+
port: target.port,
|
|
572
|
+
method,
|
|
573
|
+
path: fullPath,
|
|
574
|
+
headers: fwdHeaders,
|
|
575
|
+
...(ca ? { ca } : {}),
|
|
576
|
+
// the TLS name is the twin's: Node's agent otherwise takes it from the forwarded Host (the
|
|
577
|
+
// vendor's, e.g. api.stripe.com), and the socket guard refuses a connection naming a twinned host
|
|
578
|
+
...(target.protocol === 'https:' && !isIP(target.hostname) ? { servername: target.hostname } : {}),
|
|
579
|
+
}, (res) => {
|
|
580
|
+
const chunks = [];
|
|
581
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
582
|
+
res.on('error', reject);
|
|
583
|
+
res.on('end', () => resolve({ statusCode: res.statusCode || 200, headers: res.headers || {}, body: Buffer.concat(chunks) }));
|
|
584
|
+
});
|
|
585
|
+
req.on('error', reject);
|
|
586
|
+
if (body && body.length) req.write(body);
|
|
587
|
+
req.end();
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// A stand-in ClientRequest: collects the body, forwards to the twin on end(),
|
|
592
|
+
// then emits the twin's response as an IncomingMessage-shaped Readable.
|
|
593
|
+
function fakeClientRequest(origin, method, fullPath, headers, callback) {
|
|
594
|
+
const request = new EventEmitter();
|
|
595
|
+
const bodyChunks = [];
|
|
596
|
+
// Some SDKs (e.g. stripe-node's NodeHttpClient) only call write()/end() from
|
|
597
|
+
// inside a `req.once('socket', …)` handler — so we must emit a socket. The
|
|
598
|
+
// socket reports `connecting: false` so callers write+end immediately.
|
|
599
|
+
const socket = new EventEmitter();
|
|
600
|
+
socket.connecting = false;
|
|
601
|
+
socket.setKeepAlive = () => socket;
|
|
602
|
+
socket.setTimeout = () => socket;
|
|
603
|
+
socket.unref = () => socket;
|
|
604
|
+
socket.ref = () => socket;
|
|
605
|
+
socket.destroy = () => socket;
|
|
606
|
+
request.socket = socket;
|
|
607
|
+
request.connection = socket;
|
|
608
|
+
process.nextTick(() => request.emit('socket', socket));
|
|
609
|
+
request.write = (chunk, enc, cb) => {
|
|
610
|
+
if (chunk) bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof enc === 'string' ? enc : 'utf8'));
|
|
611
|
+
if (typeof enc === 'function') enc();
|
|
612
|
+
else if (typeof cb === 'function') cb();
|
|
613
|
+
return true;
|
|
614
|
+
};
|
|
615
|
+
// A ClientRequest emits 'finish' once its body is flushed and 'close' when
|
|
616
|
+
// the exchange ends; stream.pipeline() into a request waits for them.
|
|
617
|
+
let closed = false;
|
|
618
|
+
const close = () => {
|
|
619
|
+
if (closed) return;
|
|
620
|
+
closed = true;
|
|
621
|
+
request.destroyed = true;
|
|
622
|
+
request.emit('close');
|
|
623
|
+
};
|
|
624
|
+
request.destroyed = false;
|
|
625
|
+
request.writableFinished = false;
|
|
626
|
+
let ended = false;
|
|
627
|
+
request.end = (chunk, enc, cb) => {
|
|
628
|
+
if (typeof chunk === 'function') { cb = chunk; chunk = undefined; }
|
|
629
|
+
else if (typeof enc === 'function') { cb = enc; enc = undefined; }
|
|
630
|
+
if (ended) return request;
|
|
631
|
+
ended = true;
|
|
632
|
+
if (chunk) request.write(chunk, enc);
|
|
633
|
+
process.nextTick(() => { request.writableFinished = true; request.emit('finish'); });
|
|
634
|
+
forward(origin, method, fullPath, headers, Buffer.concat(bodyChunks))
|
|
635
|
+
.then(({ statusCode, headers: resHeaders, body }) => {
|
|
636
|
+
// A request destroyed while the twin answered never emits a response.
|
|
637
|
+
if (closed) return;
|
|
638
|
+
const res = Readable.from([body]);
|
|
639
|
+
res.once('end', close);
|
|
640
|
+
res.once('close', close);
|
|
641
|
+
res.statusCode = statusCode;
|
|
642
|
+
// Preserve Node IncomingMessage semantics. npm's pacote path classifies a missing
|
|
643
|
+
// packument from statusCode + the canonical statusMessage; the generic "Error" placeholder
|
|
644
|
+
// made an ordinary 404 fatal before npm publish could proceed to PUT.
|
|
645
|
+
res.statusMessage = http.STATUS_CODES[statusCode] || '';
|
|
646
|
+
res.headers = Object.assign({}, resHeaders);
|
|
647
|
+
res.rawHeaders = Object.entries(resHeaders).flatMap(([name, value]) => [name, String(value)]);
|
|
648
|
+
res.trailers = {};
|
|
649
|
+
res.rawTrailers = [];
|
|
650
|
+
res.complete = true;
|
|
651
|
+
res.socket = socket;
|
|
652
|
+
res.connection = socket;
|
|
653
|
+
res.httpVersion = '1.1';
|
|
654
|
+
res.httpVersionMajor = 1;
|
|
655
|
+
res.httpVersionMinor = 1;
|
|
656
|
+
if (typeof callback === 'function') callback(res);
|
|
657
|
+
request.emit('response', res);
|
|
658
|
+
if (typeof cb === 'function') cb();
|
|
659
|
+
})
|
|
660
|
+
.catch((error) => { if (!closed) request.emit('error', error); close(); });
|
|
661
|
+
return request;
|
|
662
|
+
};
|
|
663
|
+
request.setHeader = () => {};
|
|
664
|
+
request.getHeader = () => undefined;
|
|
665
|
+
request.removeHeader = () => {};
|
|
666
|
+
request.setNoDelay = () => {};
|
|
667
|
+
request.setSocketKeepAlive = () => {};
|
|
668
|
+
request.flushHeaders = () => {};
|
|
669
|
+
request.setTimeout = (_timeout, cb) => { if (typeof cb === 'function') request.once('timeout', cb); return request; };
|
|
670
|
+
request.abort = () => { request.emit('abort'); close(); };
|
|
671
|
+
request.destroy = (error) => {
|
|
672
|
+
if (error && !closed) process.nextTick(() => request.emit('error', error));
|
|
673
|
+
process.nextTick(close);
|
|
674
|
+
return request;
|
|
675
|
+
};
|
|
676
|
+
return request;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// --- unclaimed path on a twinned shared host: refuse LOUDLY, never mis-route -----------------
|
|
680
|
+
// The Cal.com incident class: a host two vendors share (www.googleapis.com) is twinned, the
|
|
681
|
+
// request's PATH belongs to neither twin (`/calendar/v3/*` — Google Calendar has no pack), and
|
|
682
|
+
// the old catch-all matcher answered it from the WRONG pack with a vendor-shaped 404: fails open
|
|
683
|
+
// AND plausible. The operator declared this host virtualized, so the traffic must not leak to the
|
|
684
|
+
// real vendor either. The only honest outcome is a refusal that NAMES the situation.
|
|
685
|
+
|
|
686
|
+
/** The configured vendors that claim `hostname` at host level (candidacy, pathname undefined). */
|
|
687
|
+
function twinnedHostVendors(hostname, map) {
|
|
688
|
+
return Object.keys(VENDOR_HOSTS).filter((vendor) => map[vendor] && VENDOR_HOSTS[vendor](hostname, undefined));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
|
|
692
|
+
const vendors = twinnedHostVendors(hostname, map);
|
|
693
|
+
return `[twin-inject] host ${hostname} is twinned in this world (configured twin${vendors.length === 1 ? '' : 's'}: ${vendors.join(', ')}), `
|
|
694
|
+
+ `but path ${pathname} belongs to no pack in this world — no twin serves that API yet. `
|
|
695
|
+
+ `Refusing loudly rather than answering from the wrong twin or leaking to the real vendor. `
|
|
696
|
+
+ `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new pack.`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function blockedClientRequest(message) {
|
|
700
|
+
const request = new EventEmitter();
|
|
701
|
+
const error = new Error(message);
|
|
702
|
+
const socket = new EventEmitter();
|
|
703
|
+
socket.connecting = false;
|
|
704
|
+
socket.setKeepAlive = () => socket;
|
|
705
|
+
socket.setTimeout = () => socket;
|
|
706
|
+
socket.unref = () => socket;
|
|
707
|
+
socket.ref = () => socket;
|
|
708
|
+
socket.destroy = () => socket;
|
|
709
|
+
request.socket = socket;
|
|
710
|
+
request.connection = socket;
|
|
711
|
+
request.write = () => true;
|
|
712
|
+
request.end = (_chunk, _enc, cb) => {
|
|
713
|
+
process.nextTick(() => {
|
|
714
|
+
request.emit('error', error);
|
|
715
|
+
if (typeof cb === 'function') cb(error);
|
|
716
|
+
});
|
|
717
|
+
return request;
|
|
718
|
+
};
|
|
719
|
+
request.setHeader = () => {};
|
|
720
|
+
request.getHeader = () => undefined;
|
|
721
|
+
request.removeHeader = () => {};
|
|
722
|
+
request.setNoDelay = () => {};
|
|
723
|
+
request.setSocketKeepAlive = () => {};
|
|
724
|
+
request.flushHeaders = () => {};
|
|
725
|
+
request.setTimeout = (_timeout, cb) => { if (typeof cb === 'function') request.once('timeout', cb); return request; };
|
|
726
|
+
request.abort = () => request.emit('abort');
|
|
727
|
+
request.destroy = () => { request.emit('close'); return request; };
|
|
728
|
+
return request;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ── SOCKET BACKSTOP ──────────────────────────────────────────────────────────────────────────
|
|
732
|
+
// The target a `net.connect`/`tls.connect` call names, in every argument form Node accepts:
|
|
733
|
+
// (options[, cb]), (port[, host][, options][, cb]), (path[, cb]). A unix path names no host.
|
|
734
|
+
// SNI counts only for TLS: Node's http Agent sets `servername` from the Host header on plain
|
|
735
|
+
// connections too, which is how a request forwarded to a loopback twin still names its vendor.
|
|
736
|
+
function socketTargetHosts(args, isTls) {
|
|
737
|
+
const [first, second, third] = args;
|
|
738
|
+
const hosts = [];
|
|
739
|
+
if (first !== null && typeof first === 'object') {
|
|
740
|
+
if (first.path !== undefined && first.host === undefined) return hosts;
|
|
741
|
+
hosts.push(first.host === undefined ? 'localhost' : first.host);
|
|
742
|
+
if (isTls && first.servername) hosts.push(first.servername);
|
|
743
|
+
return hosts;
|
|
744
|
+
}
|
|
745
|
+
if (typeof first === 'string' && !/^\d+$/.test(first)) return hosts; // a unix socket path
|
|
746
|
+
hosts.push(typeof second === 'string' ? second : 'localhost');
|
|
747
|
+
const options = typeof second === 'object' && second !== null ? second : typeof third === 'object' && third !== null ? third : null;
|
|
748
|
+
if (isTls && options && options.servername) hosts.push(options.servername);
|
|
749
|
+
return hosts;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function endpointEnvFor(vendor) {
|
|
753
|
+
try {
|
|
754
|
+
const facts = require('./generated/pack-facts.json').packs[vendor];
|
|
755
|
+
const endpoint = facts && facts.endpointEnv;
|
|
756
|
+
if (!endpoint) return null;
|
|
757
|
+
const templates = Object.keys(endpoint.templates || {});
|
|
758
|
+
return templates.length > 0 ? templates.join(', ') : endpoint.name;
|
|
759
|
+
} catch { return null; }
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function isTwinOriginHost(host) {
|
|
763
|
+
return Object.values(MAP).some((origin) => {
|
|
764
|
+
try { return new URL(origin).hostname.toLowerCase() === host; } catch { return false; }
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// WORLD DNS: a name the World routes (a vendor's host its twin serves, a host a twin claims, the application's own)
|
|
769
|
+
// resolves in a process the injector is installed in, as it resolves on the internet, including a name nothing public
|
|
770
|
+
// holds (a custom domain connected in the World, assets.<zone>): code that vets a URL before fetching it (an SSRF
|
|
771
|
+
// guard: look the name up, refuse a private address, then fetch by name, as Dub's storage does) meets the answer a
|
|
772
|
+
// public name gets. The address is one of the documentation ranges (RFC 5737's 203.0.113.0/24, RFC 3849's
|
|
773
|
+
// 2001:db8::/32): public-shaped, never private, and routed nowhere, so nothing reaches a host by it; the request by name
|
|
774
|
+
// is routed as every other one is, and a socket dialled to the address itself is refused with the reason. Every other
|
|
775
|
+
// name is the real resolver's, unchanged.
|
|
776
|
+
const WORLD_ADDRESS = { 4: '203.0.113.7', 6: '2001:db8::7' };
|
|
777
|
+
function worldResolves(hostname) {
|
|
778
|
+
const host = String(hostname || '').toLowerCase().replace(/\.$/, '');
|
|
779
|
+
return !!host && !isIP(host) && !isLocalOrPrivateHost(host) && resolveTwin(host, MAP) !== null;
|
|
780
|
+
}
|
|
781
|
+
/** dns.lookup's answer for a name the World routes, in the shape `options` asks for (family, all). */
|
|
782
|
+
function worldLookupAnswer(options) {
|
|
783
|
+
const raw = typeof options === 'number' ? options : options && options.family;
|
|
784
|
+
const family = raw === 'IPv6' || Number(raw) === 6 ? 6 : raw === 'IPv4' || Number(raw) === 4 ? 4 : 0;
|
|
785
|
+
const v6first = !!(options && typeof options === 'object' && options.order === 'ipv6first');
|
|
786
|
+
const families = family === 6 ? [6] : family === 4 ? [4] : v6first ? [6, 4] : [4, 6];
|
|
787
|
+
const all = !!(options && typeof options === 'object' && options.all);
|
|
788
|
+
return all ? families.map((f) => ({ address: WORLD_ADDRESS[f], family: f })) : { address: WORLD_ADDRESS[families[0]], family: families[0] };
|
|
789
|
+
}
|
|
790
|
+
/** Whether a lookup of `hostname` waits for the claiming twins' first read: never for a twin's own origin, whose
|
|
791
|
+
* lookup that read itself makes (a claimer at a hostname origin, as `attach` gives one, would wait on itself). */
|
|
792
|
+
function lookupWaitsForClaims(hostname) {
|
|
793
|
+
const host = String(hostname || '').toLowerCase();
|
|
794
|
+
if (!host || isIP(host) || isLocalOrPrivateHost(host) || isTwinOriginHost(host)) return null;
|
|
795
|
+
for (const origin of Object.values(MAP)) { try { if (new URL(origin).hostname.toLowerCase() === host) return null; } catch { /* not a URL */ } }
|
|
796
|
+
return claimsPendingBriefly();
|
|
797
|
+
}
|
|
798
|
+
/** The claiming twins' first read, waited a moment at most: a slow or unanswering twin delays a lookup or a fetch as a
|
|
799
|
+
* slow resolver would, and a name it claims is known from its next read. Null when every first read has landed. */
|
|
800
|
+
function claimsPendingBriefly() {
|
|
801
|
+
const pending = claimsPending(MAP);
|
|
802
|
+
return pending ? Promise.race([pending, new Promise((ok) => { const t = setTimeout(ok, 1500); if (t.unref) t.unref(); })]) : null;
|
|
803
|
+
}
|
|
804
|
+
function patchedLookup(original) {
|
|
805
|
+
return function patchedDnsLookup(hostname, options, callback) {
|
|
806
|
+
const cb = typeof options === 'function' ? options : callback;
|
|
807
|
+
const opts = typeof options === 'function' ? undefined : options;
|
|
808
|
+
if (typeof cb !== 'function') return original.apply(this, arguments);
|
|
809
|
+
const answer = () => {
|
|
810
|
+
if (!worldResolves(hostname)) return original.call(dns, hostname, opts ?? {}, cb);
|
|
811
|
+
const a = worldLookupAnswer(opts);
|
|
812
|
+
process.nextTick(() => (Array.isArray(a) ? cb(null, a) : cb(null, a.address, a.family)));
|
|
813
|
+
};
|
|
814
|
+
// a name a twin may claim is decided once its door has been read (HOSTS A TWIN CLAIMS, above)
|
|
815
|
+
const pending = lookupWaitsForClaims(hostname);
|
|
816
|
+
// the original's own throw (an invalid name) reaches the callback, as it would have
|
|
817
|
+
if (pending) pending.then(answer).catch((error) => cb(error)); else answer();
|
|
818
|
+
return {};
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function patchedPromisesLookup(original) {
|
|
822
|
+
return async function patchedDnsPromisesLookup(hostname, options) {
|
|
823
|
+
const pending = lookupWaitsForClaims(hostname);
|
|
824
|
+
if (pending) await pending;
|
|
825
|
+
return worldResolves(hostname) ? worldLookupAnswer(options) : original.call(dns.promises, hostname, options);
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function socketRefusal(hostname) {
|
|
830
|
+
const host = String(hostname || '').toLowerCase();
|
|
831
|
+
if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
|
|
832
|
+
return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
833
|
+
+ 'nothing listens there; connect by the name, which the World routes to its twin or its application.';
|
|
834
|
+
}
|
|
835
|
+
if (!host || isLocalOrPrivateHost(host) || isTwinOriginHost(host)) return null;
|
|
836
|
+
const vendors = twinnedHostVendors(host, MAP);
|
|
837
|
+
if (vendors.length > 0) {
|
|
838
|
+
const doors = vendors.map(endpointEnvFor).filter(Boolean);
|
|
839
|
+
return `[twin-inject] refused a raw socket to ${host}, which is twinned in this world (${vendors.join(', ')}): `
|
|
840
|
+
+ `the client opened its own connection instead of going through http/https or fetch, so the twin never saw it. `
|
|
841
|
+
+ (doors.length > 0
|
|
842
|
+
? `The SDK reaches the twin through ${doors.join('; ')}, which the world sets when it runs the app.`
|
|
843
|
+
: `This client needs an endpoint door on the pack before the world can serve it.`);
|
|
844
|
+
}
|
|
845
|
+
if (resolveTwin(host, MAP)?.vendor === 'app') {
|
|
846
|
+
return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
847
|
+
+ 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
|
|
848
|
+
}
|
|
849
|
+
const url = (() => { try { return new URL(`https://${isIP(host) === 6 ? `[${host}]` : host}/`); } catch { return null; } })();
|
|
850
|
+
if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${host}`;
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function refusedSocket(message) {
|
|
855
|
+
const socket = new net.Socket();
|
|
856
|
+
process.nextTick(() => socket.destroy(Object.assign(new Error(message), { code: 'ECONNREFUSED' })));
|
|
857
|
+
return socket;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function patchedConnect(originalConnect, isTls) {
|
|
861
|
+
return function patchedSocketConnect(...args) {
|
|
862
|
+
for (const host of socketTargetHosts(args, isTls)) {
|
|
863
|
+
const refusal = socketRefusal(host);
|
|
864
|
+
if (refusal) return refusedSocket(refusal);
|
|
865
|
+
}
|
|
866
|
+
return originalConnect.apply(this, args);
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let MAP = {};
|
|
871
|
+
let installed = false;
|
|
872
|
+
|
|
873
|
+
function patchedFactory(originalRequest, defaultProtocol) {
|
|
874
|
+
return function patchedRequest(...args) {
|
|
875
|
+
const { url, method, headers, callback } = describe(args, defaultProtocol);
|
|
876
|
+
const twin = url && resolveTwin(url.hostname, MAP, url.pathname);
|
|
877
|
+
if (twin && twin.vendor === 'app') {
|
|
878
|
+
const given = args.find((a) => a && typeof a === 'object' && !(a instanceof URL)) || {};
|
|
879
|
+
return requestApp(twin, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
880
|
+
}
|
|
881
|
+
if (twin) {
|
|
882
|
+
const fwdHeaders = Object.assign({}, headers);
|
|
883
|
+
if (!fwdHeaders.host && !fwdHeaders.Host) fwdHeaders.host = url.host;
|
|
884
|
+
return fakeClientRequest(twin.origin, method, url.pathname + url.search, fwdHeaders, callback);
|
|
885
|
+
}
|
|
886
|
+
// No twin claims this path — but if configured twins claim the HOST, the path is an unclaimed
|
|
887
|
+
// slice of a shared, virtualized host (the Cal.com /calendar/v3 case): refuse loudly, in every
|
|
888
|
+
// mode, before the strict-egress check gets a chance to phrase it as an ordinary untwinned host.
|
|
889
|
+
if (url && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
890
|
+
return blockedClientRequest(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP));
|
|
891
|
+
}
|
|
892
|
+
if (shouldBlockUntwinned(url)) return blockedClientRequest(`[twin-inject] blocked untwinned external request to ${url.hostname}`);
|
|
893
|
+
const request = originalRequest.apply(this, addressedToWorld(url) ? withWorldKey(args) : args);
|
|
894
|
+
// Local mode, untwinned external host, world CA in force: the TLS handshake against the
|
|
895
|
+
// real vendor is about to fail with a bare OpenSSL message — annotate it in-place via an
|
|
896
|
+
// emit wrapper (NOT an 'error' listener, which would swallow the crash-on-unhandled
|
|
897
|
+
// semantics callers rely on).
|
|
898
|
+
if (url && !isLocalOrPrivateHost(url.hostname) && worldCaIsConfigured() && request && typeof request.emit === 'function') {
|
|
899
|
+
const originalEmit = request.emit;
|
|
900
|
+
const hostname = url.hostname;
|
|
901
|
+
request.emit = function annotatedEmit(event, ...rest) {
|
|
902
|
+
if (event === 'error' && rest[0]) annotateUntwinnedTlsError(rest[0], hostname);
|
|
903
|
+
return originalEmit.call(this, event, ...rest);
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
return request;
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function install(mapOverride) {
|
|
911
|
+
const read = mapOverride || readMap(process.env);
|
|
912
|
+
MAP = Object.assign({}, MAP, read);
|
|
913
|
+
// the instance a map routes app hosts for is not a key, so Object.assign leaves it behind
|
|
914
|
+
if (read[APP_INSTANCE] || MAP[APP_INSTANCE] === undefined) Object.defineProperty(MAP, APP_INSTANCE, { value: read[APP_INSTANCE], enumerable: false, configurable: true });
|
|
915
|
+
// the claiming twins' doors are read from the start, so a process's first request finds what they claim
|
|
916
|
+
for (const vendor of Object.keys(CLAIMERS)) if (MAP[vendor]) claimsAt(MAP[vendor], CLAIMERS[vendor]);
|
|
917
|
+
const active = Object.keys(MAP);
|
|
918
|
+
if (installed || (!active.length && !worldBase() && !MAP[APP_INSTANCE] && !strictEgressEnabled() && !worldNetworkPolicyFromEnv(process.env))) return { active };
|
|
919
|
+
|
|
920
|
+
ORIGINALS = {
|
|
921
|
+
httpRequest: http.request.bind(http),
|
|
922
|
+
httpGet: http.get.bind(http),
|
|
923
|
+
httpsRequest: https.request.bind(https),
|
|
924
|
+
httpsGet: https.get.bind(https),
|
|
925
|
+
fetch: typeof globalThis.fetch === 'function' ? globalThis.fetch : null,
|
|
926
|
+
netConnect: net.connect,
|
|
927
|
+
netCreateConnection: net.createConnection,
|
|
928
|
+
tlsConnect: tls.connect,
|
|
929
|
+
dnsLookup: dns.lookup,
|
|
930
|
+
dnsPromisesLookup: dns.promises.lookup,
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
http.request = patchedFactory(ORIGINALS.httpRequest, 'http:');
|
|
934
|
+
https.request = patchedFactory(ORIGINALS.httpsRequest, 'https:');
|
|
935
|
+
http.get = function patchedHttpGet(...args) { const req = http.request(...args); req.end(); return req; };
|
|
936
|
+
https.get = function patchedHttpsGet(...args) { const req = https.request(...args); req.end(); return req; };
|
|
937
|
+
net.connect = patchedConnect(ORIGINALS.netConnect, false);
|
|
938
|
+
net.createConnection = patchedConnect(ORIGINALS.netCreateConnection, false);
|
|
939
|
+
tls.connect = patchedConnect(ORIGINALS.tlsConnect, true);
|
|
940
|
+
dns.lookup = patchedLookup(ORIGINALS.dnsLookup);
|
|
941
|
+
dns.promises.lookup = patchedPromisesLookup(ORIGINALS.dnsPromisesLookup);
|
|
942
|
+
|
|
943
|
+
if (ORIGINALS.fetch) {
|
|
944
|
+
const originalFetch = ORIGINALS.fetch;
|
|
945
|
+
globalThis.fetch = function patchedFetch(input, init, claimsWaited) {
|
|
946
|
+
let urlStr;
|
|
947
|
+
try { urlStr = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input && input.url; } catch { urlStr = undefined; }
|
|
948
|
+
let url = null;
|
|
949
|
+
if (urlStr) { try { url = new URL(urlStr); } catch { url = null; } }
|
|
950
|
+
const twin = url && resolveTwin(url.hostname, MAP, url.pathname);
|
|
951
|
+
if (!twin) {
|
|
952
|
+
// a host a twin may claim is decided once its door has been read (HOSTS A TWIN CLAIMS, above)
|
|
953
|
+
// waited once at most: a retry after the wait goes on with what is known
|
|
954
|
+
const pending = !claimsWaited && url && !isLocalOrPrivateHost(url.hostname) && !isTwinOriginHost(url.hostname) ? claimsPendingBriefly() : null;
|
|
955
|
+
if (pending) return pending.then(() => patchedFetch(input, init, true));
|
|
956
|
+
// Same shared-host rule as patchedFactory: an unclaimed path on a twinned host refuses
|
|
957
|
+
// loudly in every mode — never mis-routed, never leaked to the real vendor.
|
|
958
|
+
if (url && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
959
|
+
return Promise.reject(new Error(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP)));
|
|
960
|
+
}
|
|
961
|
+
if (shouldBlockUntwinned(url)) return Promise.reject(new Error(`[twin-inject] blocked untwinned external fetch to ${url.hostname}`));
|
|
962
|
+
// Automatic redirects run inside native fetch, past this interception
|
|
963
|
+
// point. Under an explicit World policy they must not open an unchecked
|
|
964
|
+
// second origin. Manual redirects remain readable for an SDK to follow
|
|
965
|
+
// through this same authorization door.
|
|
966
|
+
if (addressedToWorld(url) && twinsKey()) {
|
|
967
|
+
const request = typeof input === 'object' && input !== null ? input : undefined;
|
|
968
|
+
const headers = new Headers(init?.headers ?? request?.headers ?? {});
|
|
969
|
+
if (!headers.has('x-twins-key')) headers.set('x-twins-key', twinsKey());
|
|
970
|
+
const keyed = Object.assign({}, init, { headers });
|
|
971
|
+
if (!keyed.method && request?.method) keyed.method = request.method;
|
|
972
|
+
const redirect = keyed.redirect ?? request?.redirect ?? 'follow';
|
|
973
|
+
if (redirect === 'follow') {
|
|
974
|
+
if (worldNetworkPolicyFromEnv(process.env)) return originalFetch(input, Object.assign(keyed, { redirect: 'error' }));
|
|
975
|
+
keyed.redirect = 'manual';
|
|
976
|
+
return followKeyless(() => originalFetch(input, keyed), url, keyed);
|
|
977
|
+
}
|
|
978
|
+
return originalFetch(input, keyed);
|
|
979
|
+
}
|
|
980
|
+
const policy = worldNetworkPolicyFromEnv(process.env);
|
|
981
|
+
const redirect = init?.redirect ?? (typeof input === 'object' && input?.redirect) ?? 'follow';
|
|
982
|
+
const passthrough = originalFetch(input, policy && redirect === 'follow'
|
|
983
|
+
? { ...init, redirect: 'error' } : init);
|
|
984
|
+
// Same untwinned-host TLS naming as patchedFactory: fetch wraps the issuer error in
|
|
985
|
+
// "fetch failed" with the code down the `cause` chain — annotate the surfaced error.
|
|
986
|
+
if (url && !isLocalOrPrivateHost(url.hostname) && worldCaIsConfigured() && passthrough && typeof passthrough.catch === 'function') {
|
|
987
|
+
const hostname = url.hostname;
|
|
988
|
+
return passthrough.catch((error) => {
|
|
989
|
+
annotateUntwinnedTlsError(error, hostname);
|
|
990
|
+
throw error;
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
return passthrough;
|
|
994
|
+
}
|
|
995
|
+
if (twin.vendor === 'app') return fetchApp(twin, url, input, init);
|
|
996
|
+
const target = `${twin.origin.replace(/\/$/, '')}${url.pathname}${url.search}`;
|
|
997
|
+
const reqInit = Object.assign({}, init);
|
|
998
|
+
if (!reqInit.method && typeof input === 'object' && input && input.method) reqInit.method = input.method;
|
|
999
|
+
if (!reqInit.headers && typeof input === 'object' && input && input.headers) reqInit.headers = input.headers;
|
|
1000
|
+
// A Request's body is a ReadableStream, and fetch refuses a stream body without `duplex: 'half'` ("duplex option is
|
|
1001
|
+
// required when sending a body"): aws4fetch hands fetch such a Request for every signed upload.
|
|
1002
|
+
if (reqInit.body === undefined && typeof input === 'object' && input && input.body != null) {
|
|
1003
|
+
reqInit.body = input.body;
|
|
1004
|
+
if (reqInit.duplex === undefined) reqInit.duplex = 'half';
|
|
1005
|
+
}
|
|
1006
|
+
if (reqInit.redirect === undefined && typeof input === 'object' && input?.redirect) reqInit.redirect = input.redirect;
|
|
1007
|
+
if (worldNetworkPolicyFromEnv(process.env) && (reqInit.redirect === undefined || reqInit.redirect === 'follow')) reqInit.redirect = 'error';
|
|
1008
|
+
// Preserve the vendor destination for twins that serve more than one registry flavor from
|
|
1009
|
+
// one local origin. Host is honored by node:http; this explicit internal header is the
|
|
1010
|
+
// fetch-safe fallback when a runtime normalizes the forbidden Host header itself.
|
|
1011
|
+
const redirectedHeaders = new Headers(reqInit.headers || {});
|
|
1012
|
+
redirectedHeaders.set('x-volter-twin-original-host', url.host);
|
|
1013
|
+
{ const key = twinsKey(); if (key && !redirectedHeaders.has('x-twins-key')) redirectedHeaders.set('x-twins-key', key); }
|
|
1014
|
+
reqInit.headers = redirectedHeaders;
|
|
1015
|
+
// The application is routed with its own Host (native fetch sets Host from the target, and the application tells
|
|
1016
|
+
// its hostnames apart by it) through forward(), the node path; a redirect it answers is followed as fetch would,
|
|
1017
|
+
// through this same patch, so its next host is routed on its own.
|
|
1018
|
+
// Redirects are followed by followKeyless, so the key never reaches another origin. The
|
|
1019
|
+
// node path already behaves this way (the SDK sees the 30x and retries through the patch).
|
|
1020
|
+
if (twinsKey() && (reqInit.redirect === undefined || reqInit.redirect === 'follow')) {
|
|
1021
|
+
reqInit.redirect = 'manual';
|
|
1022
|
+
return followKeyless(() => originalFetch(target, reqInit), target, reqInit);
|
|
1023
|
+
}
|
|
1024
|
+
// an https twin origin (a served world's door): undici cannot use a
|
|
1025
|
+
// session CA set after process start, so route through forward() — the
|
|
1026
|
+
// node-https path that honors it — and synthesize a Response. Falls
|
|
1027
|
+
// back to the native fetch for streaming bodies (rare for SDK JSON).
|
|
1028
|
+
if (/^https:/i.test(twin.origin) && sessionCa()) {
|
|
1029
|
+
const bodyValue = reqInit.body;
|
|
1030
|
+
if (bodyValue === undefined || typeof bodyValue === 'string' || Buffer.isBuffer(bodyValue)) {
|
|
1031
|
+
const headerObj = {};
|
|
1032
|
+
const rawHeaders = reqInit.headers;
|
|
1033
|
+
if (rawHeaders && typeof rawHeaders.forEach === 'function') rawHeaders.forEach((value, key) => { headerObj[key] = value; });
|
|
1034
|
+
else if (rawHeaders) Object.assign(headerObj, rawHeaders);
|
|
1035
|
+
headerObj.host = url.host; // the vendor host, for the door's Host-routing fidelity
|
|
1036
|
+
// forward() is the SINGLE owner of origin-path prefixing (S4 review F1: this
|
|
1037
|
+
// branch used to prefix too, doubling the namespace on pathed https skins).
|
|
1038
|
+
const fullPath = `${url.pathname}${url.search}`;
|
|
1039
|
+
return forward(twin.origin, String(reqInit.method || 'GET').toUpperCase(), fullPath, headerObj, bodyValue === undefined ? undefined : Buffer.from(bodyValue))
|
|
1040
|
+
.then((res) => new Response(res.body, { status: res.statusCode, headers: res.headers }));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return originalFetch(target, reqInit);
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Bun replaces `node-fetch` imports with an internal shim bound to the
|
|
1048
|
+
// NATIVE fetch, so a library that imports node-fetch bypasses the
|
|
1049
|
+
// globalThis.fetch patch above entirely (gaxios v7 does exactly this when
|
|
1050
|
+
// `window` is absent — every google-auth token exchange would leak). The
|
|
1051
|
+
// shim's exports object is mutable and later ESM imports observe the
|
|
1052
|
+
// mutation, so re-point `default` at whatever globalThis.fetch is at CALL
|
|
1053
|
+
// time — which keeps redirect + strict-egress semantics, and stays correct
|
|
1054
|
+
// if restore() later swaps the patched fetch back out.
|
|
1055
|
+
if (typeof Bun !== 'undefined') {
|
|
1056
|
+
try {
|
|
1057
|
+
const nodeFetchShim = require('node-fetch');
|
|
1058
|
+
if (nodeFetchShim && (typeof nodeFetchShim === 'object' || typeof nodeFetchShim === 'function')) {
|
|
1059
|
+
nodeFetchShim.default = (input, init) => globalThis.fetch(input, init);
|
|
1060
|
+
}
|
|
1061
|
+
} catch { /* node-fetch not importable here — nothing to seal */ }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
installed = true;
|
|
1065
|
+
// Exact subprocess-output receipts still need the interception itself, but
|
|
1066
|
+
// infrastructure diagnostics must not become application stderr. The
|
|
1067
|
+
// attach command can opt those descendants into silence without weakening
|
|
1068
|
+
// routing or the sealed-world boundary.
|
|
1069
|
+
if (process.env.VOLTER_TWIN_INJECT_QUIET !== '1') {
|
|
1070
|
+
for (const vendor of active) {
|
|
1071
|
+
process.stderr.write(`[twin-inject] ${vendor}: redirecting to ${MAP[vendor]}\n`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return { active };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function restore() {
|
|
1078
|
+
if (!installed || !ORIGINALS) { MAP = {}; return; }
|
|
1079
|
+
http.request = ORIGINALS.httpRequest;
|
|
1080
|
+
http.get = ORIGINALS.httpGet;
|
|
1081
|
+
https.request = ORIGINALS.httpsRequest;
|
|
1082
|
+
https.get = ORIGINALS.httpsGet;
|
|
1083
|
+
if (ORIGINALS.fetch) globalThis.fetch = ORIGINALS.fetch;
|
|
1084
|
+
net.connect = ORIGINALS.netConnect;
|
|
1085
|
+
net.createConnection = ORIGINALS.netCreateConnection;
|
|
1086
|
+
tls.connect = ORIGINALS.tlsConnect;
|
|
1087
|
+
dns.lookup = ORIGINALS.dnsLookup;
|
|
1088
|
+
dns.promises.lookup = ORIGINALS.dnsPromisesLookup;
|
|
1089
|
+
installed = false;
|
|
1090
|
+
MAP = {};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
module.exports = { install, restore, readMap, resolveTwin, twinUrlVendor, twinnedHostVendors, unclaimedTwinnedHostPathMessage, appForwardHeaders, VENDOR_HOSTS };
|
|
1094
|
+
|
|
1095
|
+
// Auto-install as a preload (`--require`); strict refusal also applies without twin URLs.
|
|
1096
|
+
install();
|
|
1097
|
+
installPrismaAdapterShim();
|