@jini-ai/platform 0.3.0 → 0.5.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +121 -2
  3. package/dist/asset-cache.d.ts +0 -22
  4. package/dist/asset-cache.js +3 -138
  5. package/dist/download.js +2 -2
  6. package/dist/fs/atomic-write.d.ts +103 -0
  7. package/dist/fs/atomic-write.js +201 -0
  8. package/dist/fs/durable-json.d.ts +89 -0
  9. package/dist/fs/durable-json.js +166 -0
  10. package/dist/fs/env-text.d.ts +11 -0
  11. package/dist/fs/env-text.js +22 -0
  12. package/dist/fs/file-lock.d.ts +126 -0
  13. package/dist/fs/file-lock.js +306 -0
  14. package/dist/fs/guarded-reader.d.ts +60 -0
  15. package/dist/fs/guarded-reader.js +310 -0
  16. package/dist/fs/index.d.ts +8 -0
  17. package/dist/fs/index.js +9 -0
  18. package/dist/fs/strict-containment.d.ts +5 -0
  19. package/dist/fs/strict-containment.js +13 -0
  20. package/dist/fs.js +2 -3
  21. package/dist/http/guarded/client.d.ts +13 -0
  22. package/dist/http/guarded/client.js +240 -0
  23. package/dist/http/guarded/errors.d.ts +21 -0
  24. package/dist/http/guarded/errors.js +20 -0
  25. package/dist/http/guarded/fetch-adapter.d.ts +23 -0
  26. package/dist/http/guarded/fetch-adapter.js +30 -0
  27. package/dist/http/guarded/index.d.ts +12 -0
  28. package/dist/http/guarded/index.js +7 -0
  29. package/dist/http/guarded/node-ports.d.ts +8 -0
  30. package/dist/http/guarded/node-ports.js +11 -0
  31. package/dist/http/guarded/ports.d.ts +51 -0
  32. package/dist/http/guarded/ports.js +2 -0
  33. package/dist/http/guarded/transport.fetch.d.ts +11 -0
  34. package/dist/http/guarded/transport.fetch.js +109 -0
  35. package/dist/http/guarded/types.d.ts +8 -0
  36. package/dist/http/guarded/types.js +2 -0
  37. package/dist/index.d.ts +61 -1
  38. package/dist/index.js +61 -1
  39. package/dist/mail/adapter-module.d.ts +20 -0
  40. package/dist/mail/adapter-module.js +2 -0
  41. package/dist/mail/delivery-availability.d.ts +7 -0
  42. package/dist/mail/delivery-availability.js +6 -0
  43. package/dist/mail/index.d.ts +7 -0
  44. package/dist/mail/index.js +3 -0
  45. package/dist/mail/ports.d.ts +43 -0
  46. package/dist/mail/ports.js +2 -0
  47. package/dist/mail/purpose-scoped-mailer.d.ts +18 -0
  48. package/dist/mail/purpose-scoped-mailer.js +50 -0
  49. package/dist/mail/smtp.d.ts +79 -0
  50. package/dist/mail/smtp.js +102 -0
  51. package/dist/mail/types.d.ts +83 -0
  52. package/dist/mail/types.js +2 -0
  53. package/dist/messages.d.ts +22 -0
  54. package/dist/messages.js +15 -0
  55. package/dist/net/address.d.ts +38 -0
  56. package/dist/net/address.js +241 -0
  57. package/dist/net/index.d.ts +2 -0
  58. package/dist/net/index.js +2 -0
  59. package/dist/process.d.ts +2 -1
  60. package/dist/process.js +2 -1
  61. package/dist/proxy-env.js +1 -1
  62. package/dist/resource-paths.js +1 -1
  63. package/dist/sandbox-env.d.ts +1 -1
  64. package/dist/sandbox-env.js +1 -1
  65. package/dist/secrets/aad.d.ts +7 -0
  66. package/dist/secrets/aad.js +5 -0
  67. package/dist/secrets/credential-sets/aad.d.ts +7 -0
  68. package/dist/secrets/credential-sets/aad.js +7 -0
  69. package/dist/secrets/credential-sets/index.d.ts +4 -0
  70. package/dist/secrets/credential-sets/index.js +3 -0
  71. package/dist/secrets/credential-sets/repo.memory.d.ts +36 -0
  72. package/dist/secrets/credential-sets/repo.memory.js +72 -0
  73. package/dist/secrets/credential-sets/types.d.ts +67 -0
  74. package/dist/secrets/credential-sets/types.js +6 -0
  75. package/dist/secrets/index.d.ts +63 -0
  76. package/dist/secrets/index.js +35 -0
  77. package/dist/secrets/keyring.env.d.ts +176 -0
  78. package/dist/secrets/keyring.env.js +271 -0
  79. package/dist/secrets/keyring.memory.d.ts +10 -0
  80. package/dist/secrets/keyring.memory.js +9 -0
  81. package/dist/secrets/ports.d.ts +33 -0
  82. package/dist/secrets/ports.js +2 -0
  83. package/dist/secrets/secret-sealer.aesgcm.d.ts +22 -0
  84. package/dist/secrets/secret-sealer.aesgcm.js +64 -0
  85. package/dist/secrets/testing.d.ts +4 -0
  86. package/dist/secrets/testing.js +4 -0
  87. package/dist/secrets/types.d.ts +10 -0
  88. package/dist/secrets/types.js +2 -0
  89. package/dist/shell.d.ts +10 -6
  90. package/dist/shell.js +10 -6
  91. package/dist/terminal.d.ts +2 -2
  92. package/dist/terminal.js +9 -5
  93. package/package.json +79 -4
  94. package/dist/asset-cache.d.ts.map +0 -1
  95. package/dist/asset-cache.js.map +0 -1
  96. package/dist/aws-sigv4.d.ts.map +0 -1
  97. package/dist/aws-sigv4.js.map +0 -1
  98. package/dist/blob-storage.d.ts.map +0 -1
  99. package/dist/blob-storage.js.map +0 -1
  100. package/dist/command.d.ts.map +0 -1
  101. package/dist/command.js.map +0 -1
  102. package/dist/download.d.ts.map +0 -1
  103. package/dist/download.js.map +0 -1
  104. package/dist/fetch-with-timeout.d.ts.map +0 -1
  105. package/dist/fetch-with-timeout.js.map +0 -1
  106. package/dist/fs.d.ts.map +0 -1
  107. package/dist/fs.js.map +0 -1
  108. package/dist/home-expansion.d.ts.map +0 -1
  109. package/dist/home-expansion.js.map +0 -1
  110. package/dist/http.d.ts.map +0 -1
  111. package/dist/http.js.map +0 -1
  112. package/dist/index.d.ts.map +0 -1
  113. package/dist/index.js.map +0 -1
  114. package/dist/process.d.ts.map +0 -1
  115. package/dist/process.js.map +0 -1
  116. package/dist/proxy-env.d.ts.map +0 -1
  117. package/dist/proxy-env.js.map +0 -1
  118. package/dist/resource-paths.d.ts.map +0 -1
  119. package/dist/resource-paths.js.map +0 -1
  120. package/dist/sandbox-env.d.ts.map +0 -1
  121. package/dist/sandbox-env.js.map +0 -1
  122. package/dist/shell.d.ts.map +0 -1
  123. package/dist/shell.js.map +0 -1
  124. package/dist/terminal.d.ts.map +0 -1
  125. package/dist/terminal.js.map +0 -1
  126. package/dist/toolchain.d.ts.map +0 -1
  127. package/dist/toolchain.js.map +0 -1
@@ -0,0 +1,8 @@
1
+ export * from "../fs.js";
2
+ export * from "./atomic-write.js";
3
+ export * from "./env-text.js";
4
+ export * from "./strict-containment.js";
5
+ export * from "./guarded-reader.js";
6
+ export * from "./durable-json.js";
7
+ export { withFileLock, withFileLockSync, isLockStale } from "./file-lock.js";
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,9 @@
1
+ // Filesystem exports share atomic replacement and token/inode-owned lock primitives.
2
+ export * from "../fs.js";
3
+ export * from "./atomic-write.js";
4
+ export * from "./env-text.js";
5
+ export * from "./strict-containment.js";
6
+ export * from "./guarded-reader.js";
7
+ export * from "./durable-json.js";
8
+ export { withFileLock, withFileLockSync, isLockStale } from "./file-lock.js";
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,5 @@
1
+ export declare function resolvePathWithin({ root, segment }: {
2
+ root: string;
3
+ segment: string;
4
+ }): string | null;
5
+ //# sourceMappingURL=strict-containment.d.ts.map
@@ -0,0 +1,13 @@
1
+ /** Preserve strict lexical containment: relative roots, root equality and traversal are refused (except the filesystem-root edge case). Symlinks require a separate physical-path guard. */
2
+ import path from "node:path";
3
+ export function resolvePathWithin({ root, segment }) {
4
+ const resolved = path.resolve(root, segment);
5
+ if (resolved !== path.join(root, segment))
6
+ return null;
7
+ const normalizedRoot = path.resolve(root);
8
+ const prefix = normalizedRoot === path.sep ? normalizedRoot : `${normalizedRoot}${path.sep}`;
9
+ if (!resolved.startsWith(prefix))
10
+ return null;
11
+ return resolved;
12
+ }
13
+ //# sourceMappingURL=strict-containment.js.map
package/dist/fs.js CHANGED
@@ -75,9 +75,8 @@ export async function atomicCopyFile(sourcePath, destinationPath, options = {})
75
75
  const tempPath = join(destinationDir, `.${basename(destination)}.${process.pid}.${Date.now().toString(36)}.${Math.random().toString(36).slice(2)}.tmp`);
76
76
  try {
77
77
  await copyFile(source, tempPath);
78
- if (options.overwrite === true) {
79
- await rm(destination, { force: true });
80
- }
78
+ // Rename replaces the old file atomically. Removing it first opens a
79
+ // missing-file window and loses the previous contents if rename fails.
81
80
  await rename(tempPath, destination);
82
81
  const copied = await stat(destination);
83
82
  return { bytesCopied: copied.size, replaced: existing != null };
@@ -0,0 +1,13 @@
1
+ import type { HttpClientPort } from "@jini-ai/core/primitives";
2
+ import type { GuardedHttpRequired } from "./ports.js";
3
+ export type AddressClass = "public" | "private" | "loopback" | "link-local" | "reserved";
4
+ /** Uses the canonical net address validator; labels preserve the guarded refusal contract. */
5
+ export declare function classifyAddress({ ip }: {
6
+ ip: string;
7
+ }): AddressClass;
8
+ /** Builds an adapter-friendly outbound port. Every hop is resolved, checked, and pinned before I/O.
9
+ * Socket idle time is bounded on every hop. An optional totalDeadlineMs includes DNS,
10
+ * redirect hops and decoded body reads; it is never inferred from the idle budget.
11
+ */
12
+ export declare function createHttpClient(required: GuardedHttpRequired): HttpClientPort;
13
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,240 @@
1
+ // Policy refusals use EgressRefusedError; DNS, connection and transport failures retain their
2
+ // own errors because they are operational failures, not decisions that a different URL fixes.
3
+ import { isPrivateAddress, expandIpv6 } from "../../net/address.js";
4
+ import { FetchTimeoutError } from "../../fetch-with-timeout.js";
5
+ import { isIP } from "node:net";
6
+ import { EgressRefusedError } from "./errors.js";
7
+ /** Uses the canonical net address validator; labels preserve the guarded refusal contract. */
8
+ export function classifyAddress({ ip }) {
9
+ if (!isPrivateAddress({ address: ip })) {
10
+ if (ip.toLowerCase().startsWith("::0."))
11
+ return "reserved";
12
+ return "public";
13
+ }
14
+ if (isIP(ip) === 6) {
15
+ const g = expandIpv6({ address: ip });
16
+ if (g.slice(0, 5).every(n => n === 0) && g[5] === 0xffff) {
17
+ return classifyAddress({ ip: [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255].join(".") });
18
+ }
19
+ if (g.slice(0, 7).every(n => n === 0) && g[7] === 1)
20
+ return "loopback";
21
+ if ((g[0] & 0xffc0) === 0xfe80)
22
+ return "link-local";
23
+ if ((g[0] & 0xfe00) === 0xfc00)
24
+ return "private";
25
+ return "reserved";
26
+ }
27
+ if (isIP(ip) !== 4)
28
+ return "reserved";
29
+ const [a, b] = ip.split(".").map(Number);
30
+ if (a === 127)
31
+ return "loopback";
32
+ // This range includes the cloud metadata address 169.254.169.254.
33
+ if (a === 169 && b === 254)
34
+ return "link-local";
35
+ if (a === 0 || a >= 224)
36
+ return "reserved";
37
+ // The remaining blocked IPv4 ranges include RFC 6598 shared carrier space (100.64.0.0/10):
38
+ // it is not globally routable and belongs with RFC 1918 private ranges for egress decisions.
39
+ return "private";
40
+ }
41
+ const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
42
+ // HTTP header names are case-insensitive; preserve the caller's explicit User-Agent in any casing.
43
+ function hasUserAgentHeader(headers) {
44
+ return Object.keys(headers).some((key) => key.toLowerCase() === "user-agent");
45
+ }
46
+ function withDefaultUserAgent(headers, userAgent) {
47
+ if (hasUserAgentHeader(headers))
48
+ return { ...headers };
49
+ return { ...headers, "User-Agent": userAgent };
50
+ }
51
+ function isCrossOrigin(a, b) {
52
+ return a.protocol !== b.protocol || a.hostname !== b.hostname || a.port !== b.port;
53
+ }
54
+ function assertAllowedTarget(url, policy) {
55
+ if (!policy.allowedSchemes.includes(url.protocol.replace(":", ""))) {
56
+ const message = `scheme '${url.protocol}' is not in the allowed egress schemes`;
57
+ throw new EgressRefusedError({ message }, { callerSafeMessage: message });
58
+ }
59
+ if (url.username || url.password) {
60
+ const message = "credentials embedded in the target URL are not allowed";
61
+ throw new EgressRefusedError({ message }, { callerSafeMessage: message });
62
+ }
63
+ }
64
+ // WHATWG URL.hostname brackets IPv6 literals; IP classification, DNS and allowlists use bare IPs.
65
+ function stripIpv6Brackets(hostname) {
66
+ if (hostname.startsWith("[") && hostname.endsWith("]"))
67
+ return hostname.slice(1, -1);
68
+ return hostname;
69
+ }
70
+ // IP literals resolve to themselves and must not depend on a DNS lookup.
71
+ async function resolveHostAddresses(hostname, dns) {
72
+ const literal = stripIpv6Brackets(hostname);
73
+ if (isIP(literal) !== 0)
74
+ return [literal];
75
+ return [...await dns.resolve({ hostname })];
76
+ }
77
+ // Keep the resolved IP in internal diagnostics only; the caller-safe refusal must not disclose it.
78
+ function assertNoPrivateAddress(hostname, addresses) {
79
+ for (const address of addresses) {
80
+ const addressClass = classifyAddress({ ip: address });
81
+ if (addressClass !== "public") {
82
+ throw new EgressRefusedError({ message: `egress to '${hostname}' (${address}) rejected: resolved address is ${addressClass}` }, {
83
+ callerSafeMessage: `egress to '${hostname}' rejected: resolved address is ${addressClass}`,
84
+ });
85
+ }
86
+ }
87
+ }
88
+ async function resolvePinnedPeer(url, policy, dns) {
89
+ assertAllowedTarget(url, policy);
90
+ const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
91
+ const hostname = stripIpv6Brackets(url.hostname);
92
+ const isDevAllowlisted = policy.devHostAllowlist.includes(hostname);
93
+ const addresses = await resolveHostAddresses(url.hostname, dns);
94
+ if (addresses.length === 0) {
95
+ throw new Error(`could not resolve any address for host '${url.hostname}'`);
96
+ }
97
+ if (policy.denyPrivateAddresses && !isDevAllowlisted) {
98
+ assertNoPrivateAddress(url.hostname, addresses);
99
+ }
100
+ return {
101
+ ip: addresses[0],
102
+ port,
103
+ authority: url.host,
104
+ // RFC 6066 section 3: SNI names a hostname, never an IP literal of either family.
105
+ tlsServerName: isIP(hostname) === 0 ? url.hostname : undefined,
106
+ };
107
+ }
108
+ function withStrippedSensitiveHeaders(headers) {
109
+ const next = {};
110
+ for (const [key, value] of Object.entries(headers)) {
111
+ if (SENSITIVE_HEADERS.has(key.toLowerCase()))
112
+ continue;
113
+ next[key] = value;
114
+ }
115
+ return next;
116
+ }
117
+ function capBody(bodyText, maxBytes) {
118
+ const buffer = Buffer.from(bodyText, "utf8");
119
+ if (buffer.byteLength <= maxBytes)
120
+ return bodyText;
121
+ return buffer.subarray(0, maxBytes).toString("utf8");
122
+ }
123
+ // Cap text and raw bytes independently: invalid UTF-8 bytes become three-byte U+FFFD characters,
124
+ // so a complete binary body can have a decoded text representation that crosses the cap.
125
+ // The byte-specific flag prevents falsely treating that complete file as corrupt; the coarse
126
+ // bodyTruncated flag remains the OR for callers that consume text.
127
+ function capResponse(response, maxBytes) {
128
+ const bodyText = capBody(response.bodyText, maxBytes);
129
+ const bytes = response.bodyBytes;
130
+ const bytesOverCap = bytes !== undefined && bytes.byteLength > maxBytes;
131
+ // A producer's coarse flag names no shape and counts against both unless its byte flag says
132
+ // otherwise; assuming complete bytes from an unspecified truncation signal risks corruption.
133
+ const producerTruncated = response.bodyTruncated === true;
134
+ const bytesTruncated = bytesOverCap || (response.bodyBytesTruncated ?? producerTruncated);
135
+ return {
136
+ ...response,
137
+ bodyText,
138
+ ...(bytes !== undefined
139
+ ? { bodyBytes: bytesOverCap ? bytes.subarray(0, maxBytes) : bytes, bodyBytesTruncated: bytesTruncated }
140
+ : {}),
141
+ bodyTruncated: bodyText !== response.bodyText || bytesOverCap || producerTruncated,
142
+ };
143
+ }
144
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
145
+ async function sendWithPolicy(transport, policy, request, redirectsFollowed, dns, redirect) {
146
+ const url = new URL(request.url);
147
+ request.signal?.throwIfAborted();
148
+ const peer = await resolveBeforeDeadline(resolvePinnedPeer(url, policy, dns), request.signal);
149
+ request.signal?.throwIfAborted();
150
+ const boundedRequest = {
151
+ ...request,
152
+ // The policy timeout is a ceiling: callers may choose a shorter idle budget, never a longer one.
153
+ // Keep the legacy alias synchronized for host transport adapters. Activity resets
154
+ // Node's socket timer; a large progressing body must not spend a wall-clock budget.
155
+ idleTimeoutMs: Math.min(request.idleTimeoutMs ?? request.timeoutMs, policy.connectTimeoutMs),
156
+ timeoutMs: Math.min(request.idleTimeoutMs ?? request.timeoutMs, policy.connectTimeoutMs),
157
+ maxResponseBytes: Math.min(request.maxResponseBytes ?? Infinity, policy.maxResponseBytes, policy.maxDecompressedBytes),
158
+ };
159
+ const response = await resolveBeforeDeadline(transport.requestPinned({ request: boundedRequest, peer }), request.signal);
160
+ const effectiveCap = Math.min(request.maxResponseBytes ?? Infinity, policy.maxResponseBytes, policy.maxDecompressedBytes);
161
+ // Record the normalized URL actually resolved and pinned, not the caller's original spelling.
162
+ // A followed redirect returns the deeper hop's response, including that hop's finalUrl.
163
+ const cappedResponse = { ...capResponse(response, effectiveCap), finalUrl: url.href };
164
+ // A refused redirect is a policy decision like a refused address, so it carries the same type.
165
+ // The text names no Location target, which may be private, so both messages can share it.
166
+ if (redirect === "error" && REDIRECT_STATUSES.has(cappedResponse.status)) {
167
+ const message = "redirect response refused by the request's redirect policy";
168
+ throw new EgressRefusedError({ message }, { callerSafeMessage: message });
169
+ }
170
+ // Follow GET only even when the policy allows redirects: 307/308 preserve method and body,
171
+ // so following a mutating request would replay a write and leak its body to an unchosen target.
172
+ // With follow policy, a non-GET redirect stays a raw response rather than a target refusal.
173
+ const canFollowRedirect = redirect === "follow" && request.method === "GET" && REDIRECT_STATUSES.has(cappedResponse.status) && redirectsFollowed < policy.maxRedirects;
174
+ if (!canFollowRedirect) {
175
+ return cappedResponse;
176
+ }
177
+ const location = cappedResponse.headers.location ?? cappedResponse.headers.Location;
178
+ if (!location)
179
+ return cappedResponse;
180
+ const nextUrl = new URL(location, url);
181
+ // Cross-origin hops must not inherit authorization or cookies entrusted to the original origin.
182
+ const nextHeaders = isCrossOrigin(url, nextUrl)
183
+ ? withStrippedSensitiveHeaders(request.headers)
184
+ : request.headers;
185
+ return sendWithPolicy(transport, policy, { ...request, url: nextUrl.toString(), headers: nextHeaders }, redirectsFollowed + 1, dns, redirect);
186
+ }
187
+ /** Builds an adapter-friendly outbound port. Every hop is resolved, checked, and pinned before I/O.
188
+ * Socket idle time is bounded on every hop. An optional totalDeadlineMs includes DNS,
189
+ * redirect hops and decoded body reads; it is never inferred from the idle budget.
190
+ */
191
+ export function createHttpClient(required) {
192
+ const { transport, policy, dns, clock, userAgent } = required;
193
+ if (!userAgent.trim())
194
+ throw new TypeError("userAgent is required");
195
+ for (const value of [policy.connectTimeoutMs, policy.maxResponseBytes, policy.maxDecompressedBytes]) {
196
+ if (!Number.isSafeInteger(value) || value <= 0)
197
+ throw new TypeError("policy time and byte limits must be positive safe integers");
198
+ }
199
+ if (!Number.isSafeInteger(policy.maxRedirects) || policy.maxRedirects < 0)
200
+ throw new TypeError("maxRedirects must be a nonnegative safe integer");
201
+ return { async send({ request }, { redirect = "follow" } = {}) {
202
+ const idleTimeoutMs = request.idleTimeoutMs ?? request.timeoutMs;
203
+ if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs <= 0) {
204
+ const timeoutName = request.idleTimeoutMs === undefined ? "timeoutMs" : "idleTimeoutMs";
205
+ throw new TypeError(`${timeoutName} must be a positive safe integer`);
206
+ }
207
+ if (request.totalDeadlineMs !== undefined && (!Number.isSafeInteger(request.totalDeadlineMs) || request.totalDeadlineMs <= 0))
208
+ throw new TypeError("totalDeadlineMs must be a positive safe integer");
209
+ if (request.maxResponseBytes !== undefined && (!Number.isSafeInteger(request.maxResponseBytes) || request.maxResponseBytes <= 0))
210
+ throw new TypeError("maxResponseBytes must be a positive safe integer");
211
+ const deadline = request.totalDeadlineMs === undefined ? undefined : clock.timeoutSignal({ timeoutMs: request.totalDeadlineMs });
212
+ const signal = request.signal && deadline ? AbortSignal.any([request.signal, deadline]) : request.signal ?? deadline;
213
+ try {
214
+ return await sendWithPolicy(transport, policy, { ...request, ...(signal === undefined ? {} : { signal }), headers: withDefaultUserAgent(request.headers, userAgent) }, 0, dns, redirect);
215
+ }
216
+ catch (error) {
217
+ if (deadline?.aborted && !request.signal?.aborted)
218
+ throw new FetchTimeoutError(request.url, request.totalDeadlineMs);
219
+ throw error;
220
+ }
221
+ } };
222
+ }
223
+ async function resolveBeforeDeadline(work, signal) {
224
+ if (!signal)
225
+ return work;
226
+ let abort;
227
+ try {
228
+ return await Promise.race([work, new Promise((_resolve, reject) => {
229
+ abort = () => reject(signal.reason);
230
+ signal.addEventListener("abort", abort, { once: true });
231
+ if (signal.aborted)
232
+ abort();
233
+ })]);
234
+ }
235
+ finally {
236
+ if (abort)
237
+ signal.removeEventListener("abort", abort);
238
+ }
239
+ }
240
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,21 @@
1
+ import { type PlatformMessages } from "../../messages.js";
2
+ /** A pre-connect policy decision; callerSafeMessage never includes resolved private addresses.
3
+ * Export the refusal class so tool/HTTP boundaries can recognize a policy decision with instanceof
4
+ * instead of fragile message matching. A caller can correct a refused target; DNS failures and
5
+ * timeouts are operational failures. Reporting a policy refusal as an internal crash hides that fix.
6
+ * The full message is for server audit/logs. Only callerSafeMessage belongs at an external boundary:
7
+ * exposing resolved private addresses would let callers enumerate internal DNS one request at a time.
8
+ * Public address classifications remain actionable without revealing deployment-specific IPs.
9
+ * The safe message defaults to a generic refusal, never the full message, so omitted redaction fails
10
+ * toward disclosing less. Recognition of this class grants no access to an unguarded transport.
11
+ */
12
+ export declare class EgressRefusedError extends Error {
13
+ readonly callerSafeMessage: string;
14
+ constructor({ message }: {
15
+ message: string;
16
+ }, { callerSafeMessage, messages }?: {
17
+ callerSafeMessage?: string;
18
+ messages?: PlatformMessages;
19
+ });
20
+ }
21
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,20 @@
1
+ import { defaultPlatformMessages } from "../../messages.js";
2
+ /** A pre-connect policy decision; callerSafeMessage never includes resolved private addresses.
3
+ * Export the refusal class so tool/HTTP boundaries can recognize a policy decision with instanceof
4
+ * instead of fragile message matching. A caller can correct a refused target; DNS failures and
5
+ * timeouts are operational failures. Reporting a policy refusal as an internal crash hides that fix.
6
+ * The full message is for server audit/logs. Only callerSafeMessage belongs at an external boundary:
7
+ * exposing resolved private addresses would let callers enumerate internal DNS one request at a time.
8
+ * Public address classifications remain actionable without revealing deployment-specific IPs.
9
+ * The safe message defaults to a generic refusal, never the full message, so omitted redaction fails
10
+ * toward disclosing less. Recognition of this class grants no access to an unguarded transport.
11
+ */
12
+ export class EgressRefusedError extends Error {
13
+ callerSafeMessage;
14
+ constructor({ message }, { callerSafeMessage, messages = defaultPlatformMessages } = {}) {
15
+ super(message);
16
+ this.name = "EgressRefusedError";
17
+ this.callerSafeMessage = callerSafeMessage ?? messages.egressRefused();
18
+ }
19
+ }
20
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,23 @@
1
+ import type { HttpClientPort } from "@jini-ai/core/primitives";
2
+ import type { HttpRequest, RequestRedirect } from "@jini-ai/core/primitives";
3
+ export interface GuardedFetchOptions {
4
+ method?: HttpRequest["method"];
5
+ headers?: ConstructorParameters<typeof Headers>[0];
6
+ body?: string | URLSearchParams;
7
+ signal?: AbortSignal;
8
+ redirect?: RequestRedirect;
9
+ /** Override the legacy timeoutMs socket idle budget. */
10
+ idleTimeoutMs?: number;
11
+ /** Optional deadline over DNS, redirects and the complete decoded body. */
12
+ totalDeadlineMs?: number;
13
+ }
14
+ /** Projects guarded, complete decoded bytes into a Response for SDK/OAuth adapters.
15
+ * Defaults to redirect refusal. Hosts adapt their SDK's positional fetch signature to this object API.
16
+ * Strings and form bodies are supported; streaming uploads need a different transport contract.
17
+ */
18
+ export declare function guardedFetch({ client, url, timeoutMs }: {
19
+ client: HttpClientPort;
20
+ url: string;
21
+ timeoutMs: number;
22
+ }, { method, headers, body, signal, redirect, idleTimeoutMs, totalDeadlineMs }?: GuardedFetchOptions): Promise<Response>;
23
+ //# sourceMappingURL=fetch-adapter.d.ts.map
@@ -0,0 +1,30 @@
1
+ /** Projects guarded, complete decoded bytes into a Response for SDK/OAuth adapters.
2
+ * Defaults to redirect refusal. Hosts adapt their SDK's positional fetch signature to this object API.
3
+ * Strings and form bodies are supported; streaming uploads need a different transport contract.
4
+ */
5
+ export async function guardedFetch({ client, url, timeoutMs }, { method = "GET", headers = {}, body, signal, redirect = "error", idleTimeoutMs, totalDeadlineMs } = {}) {
6
+ const response = await client.send({ request: {
7
+ method, url, timeoutMs, headers: Object.fromEntries(new Headers(headers).entries()),
8
+ ...(body === undefined ? {} : { body: String(body) }),
9
+ ...(signal === undefined ? {} : { signal }),
10
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
11
+ ...(totalDeadlineMs === undefined ? {} : { totalDeadlineMs }),
12
+ } }, { redirect });
13
+ const truncated = response.bodyBytes === undefined
14
+ ? response.bodyTruncated : response.bodyBytesTruncated ?? response.bodyTruncated;
15
+ if (truncated)
16
+ throw new Error("guarded response was truncated by the byte policy");
17
+ const resultHeaders = new Headers(response.headers);
18
+ resultHeaders.delete("content-encoding");
19
+ resultHeaders.delete("content-length");
20
+ if (response.setCookies !== undefined) {
21
+ resultHeaders.delete("set-cookie");
22
+ for (const cookie of response.setCookies)
23
+ resultHeaders.append("set-cookie", cookie);
24
+ }
25
+ const bytes = response.bodyBytes === undefined ? response.bodyText : Buffer.from(response.bodyBytes);
26
+ // Fetch prohibits bodies for HEAD and these statuses, even when a transport supplies an empty buffer.
27
+ const bodyless = method === "HEAD" || [204, 205, 304].includes(response.status);
28
+ return new Response(bodyless ? null : bytes, { status: response.status, headers: resultHeaders });
29
+ }
30
+ //# sourceMappingURL=fetch-adapter.js.map
@@ -0,0 +1,12 @@
1
+ export { createHttpClient, classifyAddress } from "./client.js";
2
+ export type { AddressClass } from "./client.js";
3
+ export { EgressRefusedError } from "./errors.js";
4
+ export { FetchHttpTransportAdapter } from "./transport.fetch.js";
5
+ export { createNodeGuardedHttpPorts } from "./node-ports.js";
6
+ export { guardedFetch } from "./fetch-adapter.js";
7
+ export type { GuardedFetchOptions } from "./fetch-adapter.js";
8
+ export type * from "./types.js";
9
+ export type * from "./ports.js";
10
+ export { defaultPlatformMessages } from "../../messages.js";
11
+ export type { PlatformMessages } from "../../messages.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,7 @@
1
+ export { createHttpClient, classifyAddress } from "./client.js";
2
+ export { EgressRefusedError } from "./errors.js";
3
+ export { FetchHttpTransportAdapter } from "./transport.fetch.js";
4
+ export { createNodeGuardedHttpPorts } from "./node-ports.js";
5
+ export { guardedFetch } from "./fetch-adapter.js";
6
+ export { defaultPlatformMessages } from "../../messages.js";
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,8 @@
1
+ import type { GuardedClock, DnsResolver, PinnedHttpTransport } from "./ports.js";
2
+ /** Constructs native effect ports without resolving or connecting to any destination. */
3
+ export declare function createNodeGuardedHttpPorts(_required: Record<string, never>): {
4
+ dns: DnsResolver;
5
+ clock: GuardedClock;
6
+ transport: PinnedHttpTransport;
7
+ };
8
+ //# sourceMappingURL=node-ports.d.ts.map
@@ -0,0 +1,11 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { FetchHttpTransportAdapter } from "./transport.fetch.js";
3
+ /** Constructs native effect ports without resolving or connecting to any destination. */
4
+ export function createNodeGuardedHttpPorts(_required) {
5
+ return {
6
+ dns: { resolve: async ({ hostname }) => (await lookup(hostname, { all: true, verbatim: true })).map(entry => entry.address) },
7
+ clock: { nowMs: () => Date.now(), timeoutSignal: ({ timeoutMs }) => AbortSignal.timeout(timeoutMs) },
8
+ transport: new FetchHttpTransportAdapter({}),
9
+ };
10
+ }
11
+ //# sourceMappingURL=node-ports.js.map
@@ -0,0 +1,51 @@
1
+ import type { Clock, HttpRequest, HttpResponse } from "@jini-ai/core/primitives";
2
+ import type { PinnedPeer } from "./types.js";
3
+ export type { PinnedPeer } from "./types.js";
4
+ /** Resolver returns every peer address; a mixed public/private answer is refused as a whole. */
5
+ export interface DnsResolver {
6
+ resolve(required: {
7
+ hostname: string;
8
+ }): Promise<readonly string[]>;
9
+ }
10
+ /** Supplies time and deadline cancellation without a global timer dependency in the guard. */
11
+ export type GuardedClock = Clock & {
12
+ timeoutSignal(required: {
13
+ timeoutMs: number;
14
+ }): AbortSignal;
15
+ };
16
+ /** Transport must dial the supplied IP, preserve authority/SNI, bound decoded bytes,
17
+ * enforce idleTimeoutMs per socket, and honor request.signal through body completion. */
18
+ export interface PinnedHttpTransport {
19
+ requestPinned(required: {
20
+ request: HttpRequest;
21
+ peer: PinnedPeer;
22
+ }): Promise<HttpResponse>;
23
+ }
24
+ export type HttpTransportAdapter = PinnedHttpTransport;
25
+ /** Policy must cover IPv4 and IPv6, mapped-address/hostname normalization, URL credentials and
26
+ * redirects. Re-verify each redirected peer and strip sensitive headers across origins. */
27
+ export interface EgressPolicy {
28
+ readonly allowedSchemes: readonly string[];
29
+ readonly denyPrivateAddresses: boolean;
30
+ /** Explicit development capability; IPv6 literals use their bracket-free hostname (e.g. fd00::1). */
31
+ readonly devHostAllowlist: readonly string[];
32
+ readonly maxRedirects: number;
33
+ /** Historical name: ceiling on each socket's idle timeout, not total transfer time. */
34
+ readonly connectTimeoutMs: number;
35
+ readonly maxResponseBytes: number;
36
+ readonly maxDecompressedBytes: number;
37
+ /** Reserved descriptor, not an implemented pacing mechanism. Untrusted fan-out requires a host
38
+ * concurrency/budget limiter; omission is appropriate only for trusted low-fan-out callers. */
39
+ readonly rateLimit?: {
40
+ readonly maxConcurrent: number;
41
+ readonly maxPerMinute: number;
42
+ };
43
+ }
44
+ export type GuardedHttpRequired = {
45
+ transport: PinnedHttpTransport;
46
+ policy: EgressPolicy;
47
+ dns: DnsResolver;
48
+ clock: GuardedClock;
49
+ userAgent: string;
50
+ };
51
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1,11 @@
1
+ import type { HttpRequest, HttpResponse } from "@jini-ai/core/primitives";
2
+ import type { PinnedPeer } from "./types.js";
3
+ import type { HttpTransportAdapter } from "./ports.js";
4
+ export declare class FetchHttpTransportAdapter implements HttpTransportAdapter {
5
+ constructor(_required: Record<string, never>);
6
+ requestPinned({ request: req, peer }: {
7
+ request: HttpRequest;
8
+ peer: PinnedPeer;
9
+ }): Promise<HttpResponse>;
10
+ }
11
+ //# sourceMappingURL=transport.fetch.d.ts.map
@@ -0,0 +1,109 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
4
+ /**
5
+ * Dial only the policy-vetted IP, never re-resolve the URL hostname: otherwise DNS rebinding
6
+ * could change the destination between validation and connection. Preserve the original Host
7
+ * authority and hostname TLS SNI for virtual hosting and certificate validation.
8
+ * This raw transport belongs behind the guarded client, which validates each redirect hop.
9
+ * Responses are buffered; the absolute ceiling bounds memory even without a caller byte limit.
10
+ */
11
+ /** Last-resort decoded-byte bound independent of any caller's response policy. */
12
+ const ABSOLUTE_MAX_BYTES = 100 * 1024 * 1024;
13
+ export class FetchHttpTransportAdapter {
14
+ constructor(_required) { }
15
+ async requestPinned({ request: req, peer }) {
16
+ const url = new URL(req.url);
17
+ const isHttps = url.protocol === "https:";
18
+ const requestFn = isHttps ? httpsRequest : httpRequest;
19
+ const idleTimeoutMs = req.idleTimeoutMs ?? req.timeoutMs;
20
+ const options = {
21
+ method: req.method,
22
+ host: peer.ip,
23
+ port: peer.port,
24
+ path: `${url.pathname}${url.search}`,
25
+ headers: { ...req.headers, host: peer.authority },
26
+ // Node's timeout is per-socket inactivity, including the response body. Do not
27
+ // replace it with a timer over total transfer time: active large bodies can be slow.
28
+ timeout: idleTimeoutMs,
29
+ ...(req.signal ? { signal: req.signal } : {}),
30
+ // RFC 6066 forbids IP-literal SNI. Omit the option entirely when no hostname is supplied.
31
+ ...(isHttps && peer.tlsServerName ? { servername: peer.tlsServerName } : {}),
32
+ };
33
+ return new Promise((resolve, reject) => {
34
+ const clientRequest = requestFn(options, (res) => {
35
+ // HEAD can advertise GET's compression but has no body to decompress.
36
+ const decoded = req.method === "HEAD" ? res : decodeBody(res.headers["content-encoding"], res);
37
+ const chunks = [];
38
+ let totalBytes = 0;
39
+ let settled = false;
40
+ const finish = (truncated) => {
41
+ if (settled)
42
+ return;
43
+ settled = true;
44
+ // Concatenate once: text is a lossy UTF-8 view, while binary consumers retain the same
45
+ // undecoded bytes. Buffer is a Uint8Array, so returning it needs no second byte copy.
46
+ const body = Buffer.concat(chunks);
47
+ resolve({
48
+ status: res.statusCode ?? 0,
49
+ headers: flattenHeaders(res.headers),
50
+ setCookies: res.headers["set-cookie"] ?? [],
51
+ bodyText: body.toString("utf8"), bodyBytes: body,
52
+ ...(truncated ? { bodyTruncated: true, bodyBytesTruncated: true } : {}),
53
+ });
54
+ };
55
+ const cap = req.maxResponseBytes === undefined ? ABSOLUTE_MAX_BYTES : Math.min(req.maxResponseBytes, ABSOLUTE_MAX_BYTES);
56
+ decoded.on("data", (chunk) => {
57
+ if (settled)
58
+ return;
59
+ const remaining = cap - totalBytes;
60
+ if (chunk.length > remaining) {
61
+ if (req.maxResponseBytes === undefined) {
62
+ clientRequest.destroy(new Error("response exceeded the absolute size ceiling"));
63
+ return;
64
+ }
65
+ chunks.push(chunk.subarray(0, remaining));
66
+ finish(true);
67
+ decoded.destroy?.();
68
+ res.destroy();
69
+ clientRequest.destroy();
70
+ return;
71
+ }
72
+ totalBytes += chunk.length;
73
+ chunks.push(chunk);
74
+ });
75
+ decoded.on("end", () => finish(false));
76
+ decoded.on("error", reject);
77
+ });
78
+ clientRequest.on("timeout", () => {
79
+ clientRequest.destroy(new Error(`request timed out after ${idleTimeoutMs}ms`));
80
+ });
81
+ clientRequest.on("error", reject);
82
+ if (req.body !== undefined)
83
+ clientRequest.write(req.body);
84
+ clientRequest.end();
85
+ });
86
+ }
87
+ }
88
+ function decodeBody(contentEncoding, stream) {
89
+ switch (contentEncoding) {
90
+ case "gzip":
91
+ return stream.pipe(createGunzip());
92
+ case "deflate":
93
+ return stream.pipe(createInflate());
94
+ case "br":
95
+ return stream.pipe(createBrotliDecompress());
96
+ default:
97
+ return stream;
98
+ }
99
+ }
100
+ function flattenHeaders(headers) {
101
+ const flat = {};
102
+ for (const [key, value] of Object.entries(headers)) {
103
+ if (value === undefined)
104
+ continue;
105
+ flat[key] = Array.isArray(value) ? value.join(", ") : value;
106
+ }
107
+ return flat;
108
+ }
109
+ //# sourceMappingURL=transport.fetch.js.map
@@ -0,0 +1,8 @@
1
+ export interface PinnedPeer {
2
+ ip: string;
3
+ port: number;
4
+ authority: string;
5
+ /** RFC 6066 permits hostnames in SNI, never IP literals. Undefined means omit the extension. */
6
+ tlsServerName: string | undefined;
7
+ }
8
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map