@volter/twin 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/inject.cjs +453 -59
- package/package.json +12 -22
- package/src/actions.ts +234 -49
- package/src/blob-store.ts +136 -0
- package/src/changeset.ts +807 -0
- package/src/cli.ts +60 -10
- package/src/connector.ts +30 -7
- package/src/control-plane.ts +17 -1
- package/src/emit.ts +242 -0
- package/src/fork.ts +19 -7
- package/src/index.ts +139 -6
- package/src/lease.ts +4 -6
- package/src/lifecycle.ts +8 -0
- package/src/packRegistry.ts +248 -2
- package/src/plan.ts +131 -23
- package/src/proxy.ts +5 -2
- package/src/pushLedger.ts +116 -11
- package/src/queueLifecycle.ts +3 -4
- package/src/rateBudget.ts +1115 -0
- package/src/refs.ts +9 -10
- package/src/remote-execute.ts +16 -0
- package/src/scenario.ts +387 -0
- package/src/serve.ts +397 -15
- package/src/shadow.ts +86 -7
- package/src/storage.ts +76 -147
- package/src/sync.ts +63 -17
- package/src/twin-fetch.ts +115 -0
- package/src/validate.ts +6 -5
- package/src/world-clock.ts +33 -0
- package/src/world-store.ts +482 -0
- package/src/worldConfig.ts +4 -3
- package/dist/src/actions.d.ts +0 -138
- package/dist/src/actions.js +0 -201
- package/dist/src/args.d.ts +0 -3
- package/dist/src/args.js +0 -12
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.js +0 -425
- package/dist/src/connector.d.ts +0 -106
- package/dist/src/connector.js +0 -129
- package/dist/src/control-plane.d.ts +0 -21
- package/dist/src/control-plane.js +0 -40
- package/dist/src/egress.d.ts +0 -93
- package/dist/src/egress.js +0 -264
- package/dist/src/fork.d.ts +0 -126
- package/dist/src/fork.js +0 -206
- package/dist/src/index.d.ts +0 -42
- package/dist/src/index.js +0 -52
- package/dist/src/lease.d.ts +0 -50
- package/dist/src/lease.js +0 -80
- package/dist/src/packRegistry.d.ts +0 -34
- package/dist/src/packRegistry.js +0 -22
- package/dist/src/plan.d.ts +0 -97
- package/dist/src/plan.js +0 -151
- package/dist/src/proxy.d.ts +0 -25
- package/dist/src/proxy.js +0 -152
- package/dist/src/pushLedger.d.ts +0 -81
- package/dist/src/pushLedger.js +0 -130
- package/dist/src/queueLifecycle.d.ts +0 -62
- package/dist/src/queueLifecycle.js +0 -95
- package/dist/src/reconcile.d.ts +0 -58
- package/dist/src/reconcile.js +0 -137
- package/dist/src/refs.d.ts +0 -29
- package/dist/src/refs.js +0 -68
- package/dist/src/schemas.d.ts +0 -78
- package/dist/src/schemas.js +0 -50
- package/dist/src/serve.d.ts +0 -44
- package/dist/src/serve.js +0 -93
- package/dist/src/shadow.d.ts +0 -77
- package/dist/src/shadow.js +0 -138
- package/dist/src/status.d.ts +0 -31
- package/dist/src/status.js +0 -42
- package/dist/src/storage.d.ts +0 -119
- package/dist/src/storage.js +0 -535
- package/dist/src/sync.d.ts +0 -91
- package/dist/src/sync.js +0 -121
- package/dist/src/types.d.ts +0 -40
- package/dist/src/types.js +0 -1
- package/dist/src/validate.d.ts +0 -27
- package/dist/src/validate.js +0 -68
- package/dist/src/visualizer.d.ts +0 -13
- package/dist/src/visualizer.js +0 -133
- package/dist/src/worldConfig.d.ts +0 -9
- package/dist/src/worldConfig.js +0 -16
package/inject.cjs
CHANGED
|
@@ -9,13 +9,12 @@
|
|
|
9
9
|
// node --require @volter/twin/inject app.js
|
|
10
10
|
// NODE_OPTIONS="--require @volter/twin/inject" <your start command>
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
// STRIPE_TWIN_URL=http://127.0.0.1:12111
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// DYNAMODB_TWIN_URL TIMESTREAM_TWIN_URL (point these at the consolidated `aws` twin, same as S3_TWIN_URL)
|
|
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.
|
|
19
18
|
// …or combined: TWIN_INJECT="stripe=http://127.0.0.1:12111,github=http://127.0.0.1:12120"
|
|
20
19
|
//
|
|
21
20
|
// It patches http/https `request`/`get` and global `fetch` at the process
|
|
@@ -29,27 +28,133 @@ const { Readable } = require('stream');
|
|
|
29
28
|
const Module = require('module');
|
|
30
29
|
const { createPublicKey, createVerify } = require('crypto');
|
|
31
30
|
|
|
32
|
-
//
|
|
31
|
+
// ── PATH-AWARE DISAMBIGUATION (the shared-host case) ─────────────────────────────────────────
|
|
32
|
+
// A predicate is called as `(hostname, pathname)`. `pathname` is OPTIONAL and is `undefined`
|
|
33
|
+
// wherever the caller genuinely does not have one yet — most importantly the proxy's CONNECT
|
|
34
|
+
// handler, which sees only the SNI host before TLS is terminated. So the contract is:
|
|
35
|
+
//
|
|
36
|
+
// • pathname === undefined ⇒ "could this vendor serve ANY path on this host?" (candidacy).
|
|
37
|
+
// The ambient proxy uses this to decide whether to MITM at all, then re-resolves WITH the
|
|
38
|
+
// path once the request is decrypted.
|
|
39
|
+
// • pathname supplied ⇒ "does this vendor serve THIS path on this host?" (routing).
|
|
40
|
+
//
|
|
41
|
+
// Nearly every predicate ignores the second argument, because nearly every vendor owns its host
|
|
42
|
+
// outright. `www.googleapis.com` is the exception: `googleauth` serves the OAuth2 token exchange
|
|
43
|
+
// there and `youtube` serves the Data API v3 at /youtube/v3/*. Since `resolveTwin` returns the
|
|
44
|
+
// FIRST matching vendor in insertion order, a host-only match would give whichever key is declared
|
|
45
|
+
// first the entire host — silently breaking the other twin whenever both are configured in one
|
|
46
|
+
// world. The two predicates below are therefore mutually exclusive on that host BY PATH.
|
|
47
|
+
//
|
|
48
|
+
// THE SHARED-HOST RULE (Cal.com incident, 2026-08): a shared-host predicate must claim ONLY the
|
|
49
|
+
// paths its twin actually serves — never "the rest of the host". Claiming the remainder is how
|
|
50
|
+
// every `@googleapis/calendar` call (37 methods, all defaulting to www.googleapis.com) was routed
|
|
51
|
+
// into the googleauth/gemini twin and answered with a plausible Google-shaped 404: a mis-route
|
|
52
|
+
// that fails OPEN and PLAUSIBLE, strictly worse than a coverage gap. An UNCLAIMED path on a
|
|
53
|
+
// claimed host is refused LOUDLY instead (see unclaimedTwinnedHostPathMessage below): the error
|
|
54
|
+
// names the host as twinned and the path as belonging to no pack in this world.
|
|
55
|
+
/**
|
|
56
|
+
* Does this path belong to the OAuth2 token-exchange surface the googleauth twin actually serves?
|
|
57
|
+
* Exactly the routes gemini-twin.ts §googleauth models: `POST /token` (the oauth2.googleapis.com
|
|
58
|
+
* path, also answered on the legacy host) and `POST /oauth2/v4/token` (the legacy
|
|
59
|
+
* www.googleapis.com token path older google-auth-library versions default to). NOTHING else:
|
|
60
|
+
* /calendar/v3/*, /drive/v3/*, … are other Google products with no twin in this repo, and
|
|
61
|
+
* claiming them would mis-route those SDKs into an auth twin that answers with vendor-shaped
|
|
62
|
+
* 404s (the Cal.com incident).
|
|
63
|
+
*/
|
|
64
|
+
// (The `googleauth` matcher itself is DATA on gemini's descriptor — packages/twin/gemini/src/index.ts.)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Does this path belong to Google's user-facing OAuth 2.0 / OIDC surface — the one the
|
|
68
|
+
* `googleoauth` PACK serves?
|
|
69
|
+
*
|
|
70
|
+
* accounts.google.com is NOT an API host: it is Google's whole sign-in web property (the account
|
|
71
|
+
* chooser, Gmail SSO, security settings, the Workspace admin login). Claiming the host outright
|
|
72
|
+
* would be the Cal.com mis-route incident again in a much larger blast radius — every Google
|
|
73
|
+
* sign-in a browser in the world performs would land in a twin that models one protocol. So this
|
|
74
|
+
* claims EXACTLY the OAuth/OIDC paths the pack serves and refuses the rest LOUDLY
|
|
75
|
+
* (unclaimedTwinnedHostPathMessage).
|
|
76
|
+
*/
|
|
77
|
+
const isGoogleOAuthConsentPath = (p) =>
|
|
78
|
+
typeof p === 'string'
|
|
79
|
+
&& (/^\/(o\/oauth2\/v2\/auth(\/oauthchooseaccount)?|o\/oauth2\/auth|o\/oauth2\/token|signin\/oauth\/error|\.well-known\/openid-configuration)\/?$/.test(p)
|
|
80
|
+
|| /^\/_twin\//.test(p));
|
|
81
|
+
// ANCHORED, and `/o/oauth2/auth/oauthchooseaccount` deliberately absent. The first version had no
|
|
82
|
+
// terminal anchor, so `/o/oauth2/tokeninfo`, `/signin/oauth/errorPage` and
|
|
83
|
+
// `/.well-known/openid-configuration.json` were all claimed and all 404 — and its alternation also
|
|
84
|
+
// claimed an UN-VERSIONED chooser path the router does not serve (§9 round two). The claim set is
|
|
85
|
+
// now exactly `AUTH_PATHS` + the three singles + the twin-only prefix.
|
|
86
|
+
// NB `/o/oauth2/revoke` (the legacy revocation alias) is NOT claimed: the pack files it as a todo
|
|
87
|
+
// and its handler 404s it. An injector entry claiming a path the twin then refuses routes real
|
|
88
|
+
// traffic into a twin that cannot serve it — the Cal.com mis-route in miniature, and the reason
|
|
89
|
+
// every claim here is checked against what the router actually answers.
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The oauth2.googleapis.com paths the googleoauth PACK serves: the token endpoint (all three
|
|
93
|
+
* spellings), revocation, and token introspection.
|
|
94
|
+
*/
|
|
95
|
+
const isGoogleOAuthTokenPath = (p) =>
|
|
96
|
+
typeof p === 'string' && /^\/(token|oauth2\/v4\/token|revoke|tokeninfo|device\/code)\/?$/.test(p);
|
|
97
|
+
// `/device/code` is a DELIBERATE exception to "never claim a path the twin refuses", and the two
|
|
98
|
+
// rules genuinely collide here (§9 round two). The twin's own discovery document advertises
|
|
99
|
+
// `device_authorization_endpoint` — because Google's does, and dropping it would be infidelity — so
|
|
100
|
+
// a discovery-driven client WILL dial it. Unclaimed, that client escapes a sealed world and reaches
|
|
101
|
+
// the real Google; claimed, it gets the twin's honest 404. Containment wins: an unmodelled
|
|
102
|
+
// operation must fail LOCALLY, not succeed remotely.
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The www.googleapis.com paths it serves: the v1 (PEM) and v3 (JWK) cert endpoints, and the v3
|
|
106
|
+
* userinfo alias. NOT `/oauth2/v2/*` — Google publishes no v2 certs endpoint at all, and the v2
|
|
107
|
+
* userinfo alias returns a DIFFERENT field set the pack does not model
|
|
108
|
+
* (`googleoauth.endpoints.userinfo_v2`, todo), so its handler refuses it.
|
|
109
|
+
*/
|
|
110
|
+
const isGoogleOAuthApisPath = (p) =>
|
|
111
|
+
typeof p === 'string' && /^\/oauth2\/(v1\/certs|v3\/(certs|userinfo))\/?$/.test(p);
|
|
112
|
+
|
|
113
|
+
// vendor → predicate(hostname, pathname?). A vendor is only active if its twin URL is set.
|
|
114
|
+
// THE HAND TABLE'S ONLY RESIDENTS: keys served by KERNEL packages (browser-assets serves
|
|
115
|
+
// `browserassets` and `googlefavicon`; kernel packages carry no pack descriptor) and
|
|
116
|
+
// `googleoauth`, which must precede gemini's `googleauth` key on the shared token host
|
|
117
|
+
// (`resolveTwin` is first-match in insertion order, and this table is inserted before the
|
|
118
|
+
// compiled descriptors below). Every vendor pack's hosts live on its descriptor as DATA —
|
|
119
|
+
// host | suffix | hostPattern, pathPattern, exclude, key — compiled from pack-facts.json.
|
|
33
120
|
const VENDOR_HOSTS = {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
121
|
+
// google-auth-library exchanges a locally-signed JWT here before ANY
|
|
122
|
+
// Vertex/GCS call — a sealed world must model the exchange or every
|
|
123
|
+
// service-account flow dies in auth before reaching a modeled API.
|
|
124
|
+
//
|
|
125
|
+
// SHARES www.googleapis.com with the `youtube` PACK (whose `hosts` declaration carries the
|
|
126
|
+
// matching pathPattern): this vendor claims
|
|
127
|
+
// ONLY the token-exchange paths its twin serves there (`/token`, `/oauth2/v4/token` —
|
|
128
|
+
// gemini-twin.ts §googleauth; the `googleauth` key on gemini's descriptor). It used to claim "everything that
|
|
129
|
+
// is not YouTube" on that host, which silently swallowed Google Calendar (`/calendar/v3/*` —
|
|
130
|
+
// `@googleapis/calendar` defaults every method to www.googleapis.com) and answered it with
|
|
131
|
+
// plausible Google-shaped 404s from the wrong pack: the Cal.com mis-route incident. Unclaimed
|
|
132
|
+
// paths on this host now refuse loudly instead (unclaimedTwinnedHostPathMessage). With no
|
|
133
|
+
// pathname it still claims the host so the ambient proxy MITMs it; the post-decrypt resolve
|
|
134
|
+
// then routes each request to the right twin — or to the loud refusal.
|
|
135
|
+
//
|
|
136
|
+
// GOOGLEOAUTH — the real PACK (@volter/twin-googleoauth), declared BEFORE `googleauth` on purpose.
|
|
137
|
+
// `resolveTwin` returns the FIRST matching vendor in insertion order, so in a world where BOTH are
|
|
138
|
+
// configured the token endpoint goes to the pack rather than to the pack-less plumbing key — which
|
|
139
|
+
// is what you want, because the pack's token endpoint is a strict SUPERSET: it serves
|
|
140
|
+
// authorization_code and refresh_token (which googleauth has no state for) AND the
|
|
141
|
+
// jwt-bearer service-account exchange googleauth exists for, with a REAL RS256 id_token instead of
|
|
142
|
+
// gemini's `alg: none` stub. A world running ONLY the gemini twin still resolves through
|
|
143
|
+
// `googleauth` below, unchanged.
|
|
144
|
+
//
|
|
145
|
+
// FOUR hosts, every one path-scoped, because Google spreads ONE product across them and shares
|
|
146
|
+
// three of them with other products:
|
|
147
|
+
// • accounts.google.com the consent screen, the error page, OIDC discovery. NOT claimed
|
|
148
|
+
// outright: this host is Google's entire sign-in web property.
|
|
149
|
+
// • oauth2.googleapis.com token / revoke / tokeninfo. SHARED with `googleauth`.
|
|
150
|
+
// • www.googleapis.com the JWKS + legacy userinfo. SHARED with `googleauth` AND
|
|
151
|
+
// `youtube`, and disambiguated from both BY PATH.
|
|
152
|
+
// • openidconnect.googleapis.com the OIDC userinfo endpoint. Not shared.
|
|
153
|
+
googleoauth: (h, p) =>
|
|
154
|
+
(h === 'accounts.google.com' && (p === undefined || isGoogleOAuthConsentPath(p)))
|
|
155
|
+
|| (h === 'oauth2.googleapis.com' && (p === undefined || isGoogleOAuthTokenPath(p)))
|
|
156
|
+
|| (h === 'www.googleapis.com' && (p === undefined || isGoogleOAuthApisPath(p)))
|
|
157
|
+
|| h === 'openidconnect.googleapis.com',
|
|
53
158
|
googlefavicon: (h) => h === 'www.google.com' || h === 'google.com' || h === 't2.gstatic.com',
|
|
54
159
|
browserassets: (h) =>
|
|
55
160
|
h === 'cdnjs.cloudflare.com' ||
|
|
@@ -66,22 +171,51 @@ const VENDOR_HOSTS = {
|
|
|
66
171
|
h === 'www.youtube.com' ||
|
|
67
172
|
h === 'www.youtube-nocookie.com' ||
|
|
68
173
|
h === 'i.ytimg.com',
|
|
69
|
-
s3: (h) =>
|
|
70
|
-
h === 's3.amazonaws.com' ||
|
|
71
|
-
h.endsWith('.s3.amazonaws.com') ||
|
|
72
|
-
/^s3[.-][a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
73
|
-
/\.s3[.-][a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
74
|
-
/^s3\.dualstack\.[a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
75
|
-
/\.s3\.dualstack\.[a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
76
|
-
h.endsWith('.s3-accelerate.amazonaws.com') ||
|
|
77
|
-
h.endsWith('.s3-accelerate.dualstack.amazonaws.com') ||
|
|
78
|
-
h.endsWith('.r2.cloudflarestorage.com'),
|
|
79
|
-
// DynamoDB + Timestream are served by the SAME consolidated `aws` twin as s3 (it routes by
|
|
80
|
-
// X-Amz-Target). Point DYNAMODB_TWIN_URL / TIMESTREAM_TWIN_URL at the aws twin's URL.
|
|
81
|
-
dynamodb: (h) => /^dynamodb\.[a-z0-9-]+\.amazonaws\.com$/.test(h),
|
|
82
|
-
timestream: (h) => /(^|\.)(ingest|query)\.timestream\.[a-z0-9-]+\.amazonaws\.com$/.test(h),
|
|
83
174
|
};
|
|
84
175
|
|
|
176
|
+
// TWIN-PACK-CONTRACT migration: packs now declare their hosts as DATA on their descriptor
|
|
177
|
+
// (`hosts` on TwinPack), compiled into the committed pack-facts artifact — a plain JSON this
|
|
178
|
+
// preloaded, dependency-free module can `require` natively. The hand table above shrinks toward
|
|
179
|
+
// empty as entries move; a vendor present in both homes throws (dual declaration is the drift
|
|
180
|
+
// this migration ends, and a broken checkout should refuse to inject rather than half-route).
|
|
181
|
+
{
|
|
182
|
+
const { packs } = require('./generated/pack-facts.json');
|
|
183
|
+
for (const vendor of Object.keys(packs)) {
|
|
184
|
+
const rules = packs[vendor].hosts;
|
|
185
|
+
if (!rules || rules.length === 0) continue;
|
|
186
|
+
if (VENDOR_HOSTS[vendor]) {
|
|
187
|
+
throw new Error(`inject: vendor "${vendor}" declares hosts on its pack descriptor AND in the hand VENDOR_HOSTS table — one home per fact; delete the hand entry.`);
|
|
188
|
+
}
|
|
189
|
+
// Rules group by `key` (default: the vendor id) — an aws descriptor declares the s3 /
|
|
190
|
+
// dynamodb / … routing identities its consolidated twin answers under; gemini declares
|
|
191
|
+
// `googleauth`. A key matches when ANY include rule matches AND NO exclude rule matches
|
|
192
|
+
// (upstashredis: `.upstash.io` minus qstash and the vector hosts).
|
|
193
|
+
const byKey = new Map();
|
|
194
|
+
for (const rule of rules) {
|
|
195
|
+
const key = rule.key === undefined ? vendor : rule.key;
|
|
196
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
197
|
+
byKey.get(key).push(rule);
|
|
198
|
+
}
|
|
199
|
+
for (const [key, keyRules] of byKey) {
|
|
200
|
+
if (VENDOR_HOSTS[key]) {
|
|
201
|
+
throw new Error(`inject: key "${key}" (pack ${vendor}) is declared twice — on this descriptor and in the hand VENDOR_HOSTS table or another pack's descriptor; a key has one home.`);
|
|
202
|
+
}
|
|
203
|
+
const compile = (rule) => {
|
|
204
|
+
const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
|
|
205
|
+
if (selectors !== 1) throw new Error(`inject: key "${key}" (pack ${vendor}) rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
|
|
206
|
+
const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
|
|
207
|
+
const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
|
|
208
|
+
return (h, p) =>
|
|
209
|
+
(rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
|
|
210
|
+
&& (pathRe === null || p === undefined || pathRe.test(p));
|
|
211
|
+
};
|
|
212
|
+
const includes = keyRules.filter((r) => r.exclude !== true).map(compile);
|
|
213
|
+
const excludes = keyRules.filter((r) => r.exclude === true).map(compile);
|
|
214
|
+
VENDOR_HOSTS[key] = (h, p) => includes.some((m) => m(h, p)) && !excludes.some((m) => m(h, p));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
85
219
|
function decodeBase64UrlJson(part) {
|
|
86
220
|
try {
|
|
87
221
|
const padded = String(part).replace(/-/g, '+').replace(/_/g, '/');
|
|
@@ -155,10 +289,25 @@ function installClerkFastifyTwinShim() {
|
|
|
155
289
|
|
|
156
290
|
installClerkFastifyTwinShim();
|
|
157
291
|
|
|
292
|
+
// The exact inverse of readMap's env lookup: which vendor (if any) would the injector read
|
|
293
|
+
// this env var for? `S3_TWIN_URL` → 's3'; `AWS_TWIN_URL` → null (the consolidated aws twin
|
|
294
|
+
// answers as s3/dynamodb/timestream/sesv2/secretsmanager/bedrock — there IS no 'aws' vendor key, which is exactly the
|
|
295
|
+
// silent-inert-env trap this helper exists to make checkable). Shared with world-runtime so
|
|
296
|
+
// its "this injectEnv var is inert" warning can never drift from what readMap actually reads.
|
|
297
|
+
function twinEnvStem(vendor) {
|
|
298
|
+
return String(vendor).toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function twinUrlVendor(name) {
|
|
302
|
+
const match = /^([A-Z][A-Z0-9_]*)_TWIN_URL$/.exec(String(name || ''));
|
|
303
|
+
if (!match) return null;
|
|
304
|
+
return Object.keys(VENDOR_HOSTS).find((vendor) => twinEnvStem(vendor) === match[1]) || null;
|
|
305
|
+
}
|
|
306
|
+
|
|
158
307
|
function readMap(env) {
|
|
159
308
|
const map = {};
|
|
160
309
|
for (const vendor of Object.keys(VENDOR_HOSTS)) {
|
|
161
|
-
const url = env[`${vendor
|
|
310
|
+
const url = env[`${twinEnvStem(vendor)}_TWIN_URL`];
|
|
162
311
|
if (url) map[vendor] = url.replace(/\/$/, '');
|
|
163
312
|
}
|
|
164
313
|
if (env.TWIN_INJECT) {
|
|
@@ -173,9 +322,18 @@ function readMap(env) {
|
|
|
173
322
|
return map;
|
|
174
323
|
}
|
|
175
324
|
|
|
176
|
-
|
|
325
|
+
/**
|
|
326
|
+
* Which twin (if any) serves `hostname` — and, when the caller knows it, `pathname`.
|
|
327
|
+
*
|
|
328
|
+
* `pathname` is optional and backward-compatible: predicates that take one argument ignore it, so
|
|
329
|
+
* a two-argument call is identical to the old behavior for every vendor that owns its host. It
|
|
330
|
+
* matters only where two vendors SHARE a host (see the VENDOR_HOSTS header). Callers that have a
|
|
331
|
+
* URL should always pass it; callers that genuinely do not (the proxy's CONNECT handler, which
|
|
332
|
+
* sees only the SNI name) omit it and get host-level candidacy.
|
|
333
|
+
*/
|
|
334
|
+
function resolveTwin(hostname, map, pathname) {
|
|
177
335
|
for (const vendor of Object.keys(VENDOR_HOSTS)) {
|
|
178
|
-
if (map[vendor] && VENDOR_HOSTS[vendor](hostname)) return { vendor, origin: map[vendor] };
|
|
336
|
+
if (map[vendor] && VENDOR_HOSTS[vendor](hostname, pathname)) return { vendor, origin: map[vendor] };
|
|
179
337
|
}
|
|
180
338
|
return null;
|
|
181
339
|
}
|
|
@@ -201,7 +359,53 @@ function strictEgressEnabled() {
|
|
|
201
359
|
}
|
|
202
360
|
|
|
203
361
|
function shouldBlockUntwinned(url) {
|
|
204
|
-
return strictEgressEnabled() && url && !isLocalOrPrivateHost(url.hostname) && !resolveTwin(url.hostname, MAP);
|
|
362
|
+
return strictEgressEnabled() && url && !isLocalOrPrivateHost(url.hostname) && !resolveTwin(url.hostname, MAP, url.pathname);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --- untwinned-host TLS failure naming (local mode) -----------------------------------------
|
|
366
|
+
// A world's activate/shell env points the whole TLS trust story at the session CA:
|
|
367
|
+
// `SSL_CERT_FILE` — which Node honors and which REPLACES the default root store (verified on
|
|
368
|
+
// Node 22: with it set, both https.request and fetch fail EVERY real-vendor chain) — plus
|
|
369
|
+
// `NODE_EXTRA_CA_CERTS`. Twinned hosts never notice: the injector intercepts before TLS, and
|
|
370
|
+
// the ambient proxy serves session-CA leaves. But a Node request to an UNTWINNED host in
|
|
371
|
+
// local mode escapes the injector, reaches the real vendor, and dies with a bare
|
|
372
|
+
// "unable to get local issuer certificate" — while curl (whose CA story the world also
|
|
373
|
+
// scopes, but whose escape hatch differs) sails through to the real vendor. That bare
|
|
374
|
+
// OpenSSL message cost the LibreChat blind-adoption run real time: NAME the situation on the
|
|
375
|
+
// error, exactly where it surfaces (the app's request/fetch rejection).
|
|
376
|
+
const TLS_ISSUER_CODES = new Set([
|
|
377
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
378
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
379
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
380
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
381
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
// Only annotate when the world's CA env is actually in force — a TLS failure without it is a
|
|
385
|
+
// genuinely bad chain, and blaming the world would mislead. VOLTER_WORLD_CA is the world's
|
|
386
|
+
// definitive marker (proxyEnvFor sets it alongside the trust vars); SSL_CERT_FILE is the var
|
|
387
|
+
// that mechanically replaces Node's root store.
|
|
388
|
+
function worldCaSealsTls() {
|
|
389
|
+
return Boolean(process.env.VOLTER_WORLD_CA || process.env.SSL_CERT_FILE);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function untwinnedTlsExplanation(hostname) {
|
|
393
|
+
return `[twin-inject] host ${hostname} is not twinned in this world; local mode does not intercept it — this traffic goes to the REAL vendor. (Node TLS trusts only the world session CA here, so the real vendor's certificate fails verification. Use sealed mode for a strict refusal, or add a twin for ${hostname}.)`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function annotateUntwinnedTlsError(error, hostname) {
|
|
397
|
+
let cursor = error;
|
|
398
|
+
for (let depth = 0; cursor && depth < 5; depth += 1) {
|
|
399
|
+
const code = cursor.code;
|
|
400
|
+
const message = String(cursor.message || '');
|
|
401
|
+
if ((code && TLS_ISSUER_CODES.has(code)) || message.includes('unable to get local issuer certificate')) {
|
|
402
|
+
if (error instanceof Error && !String(error.message).includes('[twin-inject]')) {
|
|
403
|
+
error.message = `${error.message}\n${untwinnedTlsExplanation(hostname)}`;
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
cursor = cursor.cause;
|
|
408
|
+
}
|
|
205
409
|
}
|
|
206
410
|
|
|
207
411
|
// --- request-args → { url, method, headers, callback } across every overload ---
|
|
@@ -237,13 +441,45 @@ function describe(args, defaultProtocol) {
|
|
|
237
441
|
|
|
238
442
|
let ORIGINALS = null; // captured once, used to forward to the twin without re-entering the patch
|
|
239
443
|
|
|
444
|
+
// R7c (runtime contract): when *_TWIN_URL points at a hosted SKIN
|
|
445
|
+
// ({twinsUrl}/{org}/{world}/{vendor}), the transport key rides x-twins-key on every
|
|
446
|
+
// REWRITTEN request — its own header, never Authorization (that stays vendor
|
|
447
|
+
// semantics). One key per world: VOLTER_TWINS_KEY. Absent = local loopback twins,
|
|
448
|
+
// no key attached — exactly today's behavior.
|
|
449
|
+
function twinsKey() { return process.env.VOLTER_TWINS_KEY || null; }
|
|
450
|
+
function attachTwinsKey(headers) {
|
|
451
|
+
const key = twinsKey();
|
|
452
|
+
// Case-insensitive caller check (S4 review F6): node header iteration is
|
|
453
|
+
// last-write-wins case-insensitively, so ANY caller casing must suppress the attach.
|
|
454
|
+
if (key && !Object.keys(headers).some((k) => k.toLowerCase() === 'x-twins-key')) headers['x-twins-key'] = key;
|
|
455
|
+
return headers;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let SESSION_CA = undefined; // lazily read once; NODE_EXTRA_CA_CERTS set after process start
|
|
459
|
+
function sessionCa() {
|
|
460
|
+
if (SESSION_CA === undefined) {
|
|
461
|
+
const caPath = process.env.NODE_EXTRA_CA_CERTS;
|
|
462
|
+
try { SESSION_CA = caPath ? require('fs').readFileSync(caPath) : null; } catch { SESSION_CA = null; }
|
|
463
|
+
}
|
|
464
|
+
return SESSION_CA;
|
|
465
|
+
}
|
|
466
|
+
|
|
240
467
|
function forward(origin, method, fullPath, headers, body) {
|
|
241
468
|
return new Promise((resolve, reject) => {
|
|
242
469
|
let target;
|
|
243
470
|
try { target = new URL(origin); } catch (error) { reject(new Error(`bad twin URL "${origin}": ${error.message}`)); return; }
|
|
244
471
|
const mod = target.protocol === 'https:' ? ORIGINALS.httpsRequest : ORIGINALS.httpRequest;
|
|
245
|
-
const fwdHeaders = Object.assign({}, headers);
|
|
472
|
+
const fwdHeaders = attachTwinsKey(Object.assign({}, headers));
|
|
246
473
|
delete fwdHeaders['accept-encoding']; delete fwdHeaders['Accept-Encoding'];
|
|
474
|
+
// A SKIN origin carries its namespace as a PATH (/{org}/{world}/{vendor}) — prefix
|
|
475
|
+
// it, or the hosted twin is addressed at the wrong root (R7c). Loopback origins have
|
|
476
|
+
// pathname '/', which prefixes to nothing: local behavior unchanged.
|
|
477
|
+
const originPath = target.pathname.replace(/\/$/, '');
|
|
478
|
+
fullPath = `${originPath}${fullPath}`;
|
|
479
|
+
// an https twin origin (a served world's door) verifies against the session
|
|
480
|
+
// CA even when NODE_EXTRA_CA_CERTS was set after process start (the rung-2
|
|
481
|
+
// dormant attach does exactly that)
|
|
482
|
+
const ca = target.protocol === 'https:' ? sessionCa() : null;
|
|
247
483
|
const req = mod({
|
|
248
484
|
protocol: target.protocol,
|
|
249
485
|
hostname: target.hostname,
|
|
@@ -251,6 +487,7 @@ function forward(origin, method, fullPath, headers, body) {
|
|
|
251
487
|
method,
|
|
252
488
|
path: fullPath,
|
|
253
489
|
headers: fwdHeaders,
|
|
490
|
+
...(ca ? { ca } : {}),
|
|
254
491
|
}, (res) => {
|
|
255
492
|
const chunks = [];
|
|
256
493
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
@@ -295,9 +532,20 @@ function fakeClientRequest(origin, method, fullPath, headers, callback) {
|
|
|
295
532
|
.then(({ statusCode, headers: resHeaders, body }) => {
|
|
296
533
|
const res = Readable.from([body]);
|
|
297
534
|
res.statusCode = statusCode;
|
|
298
|
-
|
|
535
|
+
// Preserve Node IncomingMessage semantics. npm's pacote path classifies a missing
|
|
536
|
+
// packument from statusCode + the canonical statusMessage; the generic "Error" placeholder
|
|
537
|
+
// made an ordinary 404 fatal before npm publish could proceed to PUT.
|
|
538
|
+
res.statusMessage = http.STATUS_CODES[statusCode] || '';
|
|
299
539
|
res.headers = Object.assign({}, resHeaders);
|
|
300
|
-
res.rawHeaders = [];
|
|
540
|
+
res.rawHeaders = Object.entries(resHeaders).flatMap(([name, value]) => [name, String(value)]);
|
|
541
|
+
res.trailers = {};
|
|
542
|
+
res.rawTrailers = [];
|
|
543
|
+
res.complete = true;
|
|
544
|
+
res.socket = socket;
|
|
545
|
+
res.connection = socket;
|
|
546
|
+
res.httpVersion = '1.1';
|
|
547
|
+
res.httpVersionMajor = 1;
|
|
548
|
+
res.httpVersionMinor = 1;
|
|
301
549
|
if (typeof callback === 'function') callback(res);
|
|
302
550
|
request.emit('response', res);
|
|
303
551
|
if (typeof cb === 'function') cb();
|
|
@@ -317,9 +565,29 @@ function fakeClientRequest(origin, method, fullPath, headers, callback) {
|
|
|
317
565
|
return request;
|
|
318
566
|
}
|
|
319
567
|
|
|
320
|
-
|
|
568
|
+
// --- unclaimed path on a twinned shared host: refuse LOUDLY, never mis-route -----------------
|
|
569
|
+
// The Cal.com incident class: a host two vendors share (www.googleapis.com) is twinned, the
|
|
570
|
+
// request's PATH belongs to neither twin (`/calendar/v3/*` — Google Calendar has no pack), and
|
|
571
|
+
// the old catch-all matcher answered it from the WRONG pack with a vendor-shaped 404: fails open
|
|
572
|
+
// AND plausible. The operator declared this host virtualized, so the traffic must not leak to the
|
|
573
|
+
// real vendor either. The only honest outcome is a refusal that NAMES the situation.
|
|
574
|
+
|
|
575
|
+
/** The configured vendors that claim `hostname` at host level (candidacy, pathname undefined). */
|
|
576
|
+
function twinnedHostVendors(hostname, map) {
|
|
577
|
+
return Object.keys(VENDOR_HOSTS).filter((vendor) => map[vendor] && VENDOR_HOSTS[vendor](hostname, undefined));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
|
|
581
|
+
const vendors = twinnedHostVendors(hostname, map);
|
|
582
|
+
return `[twin-inject] host ${hostname} is twinned in this world (configured twin${vendors.length === 1 ? '' : 's'}: ${vendors.join(', ')}), `
|
|
583
|
+
+ `but path ${pathname} belongs to no pack in this world — no twin serves that API yet. `
|
|
584
|
+
+ `Refusing loudly rather than answering from the wrong twin or leaking to the real vendor. `
|
|
585
|
+
+ `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new pack.`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function blockedClientRequest(message) {
|
|
321
589
|
const request = new EventEmitter();
|
|
322
|
-
const error = new Error(
|
|
590
|
+
const error = new Error(message);
|
|
323
591
|
const socket = new EventEmitter();
|
|
324
592
|
socket.connecting = false;
|
|
325
593
|
socket.setKeepAlive = () => socket;
|
|
@@ -355,14 +623,33 @@ let installed = false;
|
|
|
355
623
|
function patchedFactory(originalRequest, defaultProtocol) {
|
|
356
624
|
return function patchedRequest(...args) {
|
|
357
625
|
const { url, method, headers, callback } = describe(args, defaultProtocol);
|
|
358
|
-
const twin = url && resolveTwin(url.hostname, MAP);
|
|
626
|
+
const twin = url && resolveTwin(url.hostname, MAP, url.pathname);
|
|
359
627
|
if (twin) {
|
|
360
628
|
const fwdHeaders = Object.assign({}, headers);
|
|
361
629
|
if (!fwdHeaders.host && !fwdHeaders.Host) fwdHeaders.host = url.host;
|
|
362
630
|
return fakeClientRequest(twin.origin, method, url.pathname + url.search, fwdHeaders, callback);
|
|
363
631
|
}
|
|
364
|
-
if
|
|
365
|
-
|
|
632
|
+
// No twin claims this path — but if configured twins claim the HOST, the path is an unclaimed
|
|
633
|
+
// slice of a shared, virtualized host (the Cal.com /calendar/v3 case): refuse loudly, in every
|
|
634
|
+
// mode, before the strict-egress check gets a chance to phrase it as an ordinary untwinned host.
|
|
635
|
+
if (url && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
636
|
+
return blockedClientRequest(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP));
|
|
637
|
+
}
|
|
638
|
+
if (shouldBlockUntwinned(url)) return blockedClientRequest(`[twin-inject] blocked untwinned external request to ${url.hostname}`);
|
|
639
|
+
const request = originalRequest.apply(this, args);
|
|
640
|
+
// Local mode, untwinned external host, world CA in force: the TLS handshake against the
|
|
641
|
+
// real vendor is about to fail with a bare OpenSSL message — annotate it in-place via an
|
|
642
|
+
// emit wrapper (NOT an 'error' listener, which would swallow the crash-on-unhandled
|
|
643
|
+
// semantics callers rely on).
|
|
644
|
+
if (url && !isLocalOrPrivateHost(url.hostname) && worldCaSealsTls() && request && typeof request.emit === 'function') {
|
|
645
|
+
const originalEmit = request.emit;
|
|
646
|
+
const hostname = url.hostname;
|
|
647
|
+
request.emit = function annotatedEmit(event, ...rest) {
|
|
648
|
+
if (event === 'error' && rest[0]) annotateUntwinnedTlsError(rest[0], hostname);
|
|
649
|
+
return originalEmit.call(this, event, ...rest);
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return request;
|
|
366
653
|
};
|
|
367
654
|
}
|
|
368
655
|
|
|
@@ -391,23 +678,130 @@ function install(mapOverride) {
|
|
|
391
678
|
try { urlStr = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input && input.url; } catch { urlStr = undefined; }
|
|
392
679
|
let url = null;
|
|
393
680
|
if (urlStr) { try { url = new URL(urlStr); } catch { url = null; } }
|
|
394
|
-
const twin = url && resolveTwin(url.hostname, MAP);
|
|
681
|
+
const twin = url && resolveTwin(url.hostname, MAP, url.pathname);
|
|
395
682
|
if (!twin) {
|
|
683
|
+
// Same shared-host rule as patchedFactory: an unclaimed path on a twinned host refuses
|
|
684
|
+
// loudly in every mode — never mis-routed, never leaked to the real vendor.
|
|
685
|
+
if (url && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
686
|
+
return Promise.reject(new Error(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP)));
|
|
687
|
+
}
|
|
396
688
|
if (shouldBlockUntwinned(url)) return Promise.reject(new Error(`[twin-inject] blocked untwinned external fetch to ${url.hostname}`));
|
|
397
|
-
|
|
689
|
+
const passthrough = originalFetch(input, init);
|
|
690
|
+
// Same untwinned-host TLS naming as patchedFactory: fetch wraps the issuer error in
|
|
691
|
+
// "fetch failed" with the code down the `cause` chain — annotate the surfaced error.
|
|
692
|
+
if (url && !isLocalOrPrivateHost(url.hostname) && worldCaSealsTls() && passthrough && typeof passthrough.catch === 'function') {
|
|
693
|
+
const hostname = url.hostname;
|
|
694
|
+
return passthrough.catch((error) => {
|
|
695
|
+
annotateUntwinnedTlsError(error, hostname);
|
|
696
|
+
throw error;
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return passthrough;
|
|
398
700
|
}
|
|
399
|
-
const target = `${twin.origin}${url.pathname}${url.search}`;
|
|
701
|
+
const target = `${twin.origin.replace(/\/$/, '')}${url.pathname}${url.search}`;
|
|
400
702
|
const reqInit = Object.assign({}, init);
|
|
401
703
|
if (!reqInit.method && typeof input === 'object' && input && input.method) reqInit.method = input.method;
|
|
402
704
|
if (!reqInit.headers && typeof input === 'object' && input && input.headers) reqInit.headers = input.headers;
|
|
403
705
|
if (reqInit.body === undefined && typeof input === 'object' && input && input.body != null) reqInit.body = input.body;
|
|
706
|
+
// Preserve the vendor destination for twins that serve more than one registry flavor from
|
|
707
|
+
// one local origin. Host is honored by node:http; this explicit internal header is the
|
|
708
|
+
// fetch-safe fallback when a runtime normalizes the forbidden Host header itself.
|
|
709
|
+
const redirectedHeaders = new Headers(reqInit.headers || {});
|
|
710
|
+
redirectedHeaders.set('x-volter-twin-original-host', url.host);
|
|
711
|
+
{ const key = twinsKey(); if (key && !redirectedHeaders.has('x-twins-key')) redirectedHeaders.set('x-twins-key', key); }
|
|
712
|
+
reqInit.headers = redirectedHeaders;
|
|
713
|
+
// S4 review F3: undici's default redirect-follow forwards CUSTOM headers across
|
|
714
|
+
// origins — a twin answering 30x to an external URL would carry x-twins-key to a
|
|
715
|
+
// REAL host. Follow ourselves instead: a twin-resolvable hop re-enters this patch
|
|
716
|
+
// (key re-attached correctly); anything else is fetched WITHOUT the key. The node
|
|
717
|
+
// path already behaves this way (the SDK sees the 30x and retries through the
|
|
718
|
+
// patch), so the two rewrite paths now agree.
|
|
719
|
+
if (twinsKey() && (reqInit.redirect === undefined || reqInit.redirect === 'follow')) {
|
|
720
|
+
reqInit.redirect = 'manual';
|
|
721
|
+
const followedFetch = async () => {
|
|
722
|
+
let hop = await originalFetch(target, reqInit);
|
|
723
|
+
let hopUrl = target;
|
|
724
|
+
let hopMethod = String(reqInit.method || 'GET').toUpperCase();
|
|
725
|
+
let hopBody = reqInit.body;
|
|
726
|
+
for (let hops = 0; hops < 5; hops += 1) {
|
|
727
|
+
const location = hop.status >= 300 && hop.status < 400 ? hop.headers.get('location') : null;
|
|
728
|
+
if (!location) return hop;
|
|
729
|
+
// RFC semantics per hop (audit M7): 303 → GET; 301/302 demote POST to GET
|
|
730
|
+
// (the browser convention SDKs expect); 307/308 preserve method AND body.
|
|
731
|
+
if (hop.status === 303 || ((hop.status === 301 || hop.status === 302) && hopMethod === 'POST')) {
|
|
732
|
+
hopMethod = 'GET';
|
|
733
|
+
hopBody = undefined;
|
|
734
|
+
}
|
|
735
|
+
// Resolve against the CURRENT hop (a relative Location from hop 2 is
|
|
736
|
+
// relative to hop 2), cancel the interim body, and re-enter the PATCHED
|
|
737
|
+
// fetch with the ORIGINAL headers minus the twins key — a twin-resolvable
|
|
738
|
+
// hop gets the key re-attached by the patch; an external hop stays keyless.
|
|
739
|
+
const nextUrl = new URL(location, hopUrl);
|
|
740
|
+
try { hop.body && (await hop.body.cancel()); } catch { /* interim body */ }
|
|
741
|
+
const hopHeaders = new Headers(reqInit.headers || {});
|
|
742
|
+
hopHeaders.delete('x-twins-key');
|
|
743
|
+
hopHeaders.delete('x-volter-twin-original-host');
|
|
744
|
+
hop = await globalThis.fetch(nextUrl, {
|
|
745
|
+
method: hopMethod,
|
|
746
|
+
headers: hopHeaders,
|
|
747
|
+
...(hopBody === undefined ? {} : { body: hopBody }),
|
|
748
|
+
redirect: 'manual',
|
|
749
|
+
});
|
|
750
|
+
hopUrl = nextUrl;
|
|
751
|
+
}
|
|
752
|
+
return hop;
|
|
753
|
+
};
|
|
754
|
+
return followedFetch();
|
|
755
|
+
}
|
|
756
|
+
// an https twin origin (a served world's door): undici cannot use a
|
|
757
|
+
// session CA set after process start, so route through forward() — the
|
|
758
|
+
// node-https path that honors it — and synthesize a Response. Falls
|
|
759
|
+
// back to the native fetch for streaming bodies (rare for SDK JSON).
|
|
760
|
+
if (/^https:/i.test(twin.origin) && sessionCa()) {
|
|
761
|
+
const bodyValue = reqInit.body;
|
|
762
|
+
if (bodyValue === undefined || typeof bodyValue === 'string' || Buffer.isBuffer(bodyValue)) {
|
|
763
|
+
const headerObj = {};
|
|
764
|
+
const rawHeaders = reqInit.headers;
|
|
765
|
+
if (rawHeaders && typeof rawHeaders.forEach === 'function') rawHeaders.forEach((value, key) => { headerObj[key] = value; });
|
|
766
|
+
else if (rawHeaders) Object.assign(headerObj, rawHeaders);
|
|
767
|
+
headerObj.host = url.host; // the vendor host, for the door's Host-routing fidelity
|
|
768
|
+
// forward() is the SINGLE owner of origin-path prefixing (S4 review F1: this
|
|
769
|
+
// branch used to prefix too, doubling the namespace on pathed https skins).
|
|
770
|
+
const fullPath = `${url.pathname}${url.search}`;
|
|
771
|
+
return forward(twin.origin, String(reqInit.method || 'GET').toUpperCase(), fullPath, headerObj, bodyValue === undefined ? undefined : Buffer.from(bodyValue))
|
|
772
|
+
.then((res) => new Response(res.body, { status: res.statusCode, headers: res.headers }));
|
|
773
|
+
}
|
|
774
|
+
}
|
|
404
775
|
return originalFetch(target, reqInit);
|
|
405
776
|
};
|
|
406
777
|
}
|
|
407
778
|
|
|
779
|
+
// Bun replaces `node-fetch` imports with an internal shim bound to the
|
|
780
|
+
// NATIVE fetch, so a library that imports node-fetch bypasses the
|
|
781
|
+
// globalThis.fetch patch above entirely (gaxios v7 does exactly this when
|
|
782
|
+
// `window` is absent — every google-auth token exchange would leak). The
|
|
783
|
+
// shim's exports object is mutable and later ESM imports observe the
|
|
784
|
+
// mutation, so re-point `default` at whatever globalThis.fetch is at CALL
|
|
785
|
+
// time — which keeps redirect + strict-egress semantics, and stays correct
|
|
786
|
+
// if restore() later swaps the patched fetch back out.
|
|
787
|
+
if (typeof Bun !== 'undefined') {
|
|
788
|
+
try {
|
|
789
|
+
const nodeFetchShim = require('node-fetch');
|
|
790
|
+
if (nodeFetchShim && (typeof nodeFetchShim === 'object' || typeof nodeFetchShim === 'function')) {
|
|
791
|
+
nodeFetchShim.default = (input, init) => globalThis.fetch(input, init);
|
|
792
|
+
}
|
|
793
|
+
} catch { /* node-fetch not importable here — nothing to seal */ }
|
|
794
|
+
}
|
|
795
|
+
|
|
408
796
|
installed = true;
|
|
409
|
-
|
|
410
|
-
|
|
797
|
+
// Exact subprocess-output receipts still need the interception itself, but
|
|
798
|
+
// infrastructure diagnostics must not become application stderr. The
|
|
799
|
+
// attach command can opt those descendants into silence without weakening
|
|
800
|
+
// routing or the sealed-world boundary.
|
|
801
|
+
if (process.env.VOLTER_TWIN_INJECT_QUIET !== '1') {
|
|
802
|
+
for (const vendor of active) {
|
|
803
|
+
process.stderr.write(`[twin-inject] ${vendor}: redirecting to ${MAP[vendor]}\n`);
|
|
804
|
+
}
|
|
411
805
|
}
|
|
412
806
|
return { active };
|
|
413
807
|
}
|
|
@@ -423,7 +817,7 @@ function restore() {
|
|
|
423
817
|
MAP = {};
|
|
424
818
|
}
|
|
425
819
|
|
|
426
|
-
module.exports = { install, restore, readMap, resolveTwin, VENDOR_HOSTS };
|
|
820
|
+
module.exports = { install, restore, readMap, resolveTwin, twinUrlVendor, twinnedHostVendors, unclaimedTwinnedHostPathMessage, VENDOR_HOSTS };
|
|
427
821
|
|
|
428
822
|
// Auto-install when loaded as a preload (`--require`). No-op if no twin URLs are set.
|
|
429
823
|
install();
|