@fluid-app/exigo-connection-sdk 0.1.13 → 0.1.15

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 CHANGED
@@ -52,9 +52,9 @@ export const resolveExigoCredentials = defineExigoCredentials(
52
52
  database: c.exigoDbName,
53
53
  username: c.exigoDbUsername,
54
54
  password: c.exigoDbPassword,
55
- // Required, no default: Exigo's replicas are Azure SQL, whose
56
- // certificate does not match the host you connect to, so this must
57
- // be true. It skips server certificate verification.
55
+ // Required, no default. true skips server certificate verification,
56
+ // which Exigo's replicas need: they present a certificate that does
57
+ // not verify against the host (e.g. SQL Server's self-signed one).
58
58
  trustServerCertificate: true,
59
59
  },
60
60
  };
@@ -154,8 +154,7 @@ const exigo = createExigoRestClient(rest, {
154
154
  reach Exigo, 503 when it could not verify the token or is at capacity.
155
155
  - Clients built with the same proxy URL and token share one agent, so a
156
156
  client per request still reuses open tunnels.
157
- - REST only. The proxy does not carry the SQL replica's port, and `mssql`
158
- cannot use a proxy.
157
+ - SQL goes through the same proxy; see [SQL through the egress proxy](#through-fluids-egress-proxy).
159
158
 
160
159
  ## 3. SQL
161
160
 
@@ -211,6 +210,44 @@ export default { serverExternalPackages: ["mssql"] };
211
210
  export const runtime = "nodejs";
212
211
  ```
213
212
 
213
+ ### Through Fluid's egress proxy
214
+
215
+ The same proxy as for REST, for the same reason: Exigo allowlists the replica
216
+ by source IP. `mssql` ignores proxy settings, so the pool hands tedious a
217
+ connector that opens a CONNECT tunnel to the replica's host and port; TDS,
218
+ including its TLS handshake, then runs through the tunnel unchanged. No extra
219
+ package is needed.
220
+
221
+ ```ts
222
+ const pool = await getExigoPool(creds, {
223
+ egressProxy: process.env.FLUID_EGRESS_PROXY_URL
224
+ ? {
225
+ url: process.env.FLUID_EGRESS_PROXY_URL,
226
+ token: installation.authenticationToken, // dit_…
227
+ }
228
+ : undefined,
229
+ });
230
+ ```
231
+
232
+ - The proxy must allow the replica's host and port (1433), and Exigo must
233
+ allowlist the proxy's IP on the SQL side.
234
+ - The proxy and token are part of the pool's identity: a call with a different
235
+ one gets its own pool.
236
+ - Encryption is required through the proxy. `encrypt: false` is refused
237
+ (`ExigoCredentialsError`). And because SQL Server negotiates TLS (tedious
238
+ skips it, sending the login with the password merely obfuscated, when the
239
+ server's PRELOGIN answer does not offer encryption), the connector reads that
240
+ answer and hangs up before the login unless encryption is `ON` or `REQ`,
241
+ throwing `ExigoConnectionError`. Direct connections are not checked this way.
242
+ - Refusals throw `ExigoProxyError` with `source: "sql"`; a proxy that cannot be
243
+ reached throws `ExigoConnectionError` naming it.
244
+ - **Known gap:** the connection is encrypted, but with
245
+ `trustServerCertificate: true` nothing proves the other end of the TLS is
246
+ Exigo. The proxy reads nothing inside the tunnel, yet anything between the
247
+ proxy and Exigo that impersonates the replica could read the login. Direct
248
+ connections carry the same gap. Closing it needs a certificate on Exigo's
249
+ side that can be verified.
250
+
214
251
  ## Errors
215
252
 
216
253
  Every error extends `ExigoError`. None of them carries a password or an
@@ -0,0 +1,24 @@
1
+ //#region src/egress-proxy.d.ts
2
+ /**
3
+ * Fluid's egress proxy: a CONNECT-only forward proxy with a static outbound IP
4
+ * that Exigo tenants allowlist, for apps on hosts whose egress IPs are not
5
+ * Fluid's (Mist apps on Vercel). It tunnels bytes without reading them; what
6
+ * keeps the Exigo credentials from it is the TLS inside the tunnel.
7
+ */
8
+ interface ExigoEgressProxy {
9
+ /**
10
+ * The proxy URL, which Fluid provides to a droplet-linked Mist app as
11
+ * `FLUID_EGRESS_PROXY_URL`. Must be https: the CONNECT request carries the
12
+ * token outside the tunnel. Plain http is accepted only for a loopback host,
13
+ * such as a proxy run locally.
14
+ */
15
+ readonly url: string;
16
+ /**
17
+ * The droplet installation token (`dit_…`, the `authentication_token` of the
18
+ * install webhook) the proxy authenticates the app with. A `Bearer ` prefix
19
+ * is optional.
20
+ */
21
+ readonly token: string;
22
+ }
23
+ //#endregion
24
+ export { ExigoEgressProxy };
@@ -0,0 +1,53 @@
1
+ import { ExigoCredentialsError } from "./errors.mjs";
2
+ //#region src/egress-proxy.ts
3
+ /**
4
+ * Fluid's egress proxy, as both clients use it: what an app passes, how it is
5
+ * checked, and what a refusal means. No Node-only APIs, so `./rest` can import
6
+ * it; the SQL tunnel itself lives in `./sql-tunnel`.
7
+ */
8
+ const LOOPBACK_HOSTS = new Set([
9
+ "localhost",
10
+ "127.0.0.1",
11
+ "[::1]"
12
+ ]);
13
+ /** Same rule as the REST base URL: the CONNECT request carries the token in clear. */
14
+ function normalizeUrl(proxyUrl) {
15
+ let url;
16
+ try {
17
+ url = new URL(proxyUrl.trim());
18
+ } catch {
19
+ throw new ExigoCredentialsError("Exigo egressProxy.url is not a valid URL.", ["egressProxy.url"]);
20
+ }
21
+ const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
22
+ if (url.protocol !== "https:" && !loopbackHttp) throw new ExigoCredentialsError("Exigo egressProxy.url must be an https URL; the CONNECT request carries the token.", ["egressProxy.url"]);
23
+ if (url.username || url.password) throw new ExigoCredentialsError("Exigo egressProxy.url must not carry credentials; pass the token instead.", ["egressProxy.url"]);
24
+ return url;
25
+ }
26
+ function normalizeToken(token) {
27
+ const bare = token.replace(/^\s*Bearer(?:\s+|$)/i, "").trim();
28
+ if (bare === "" || /\s/.test(bare)) throw new ExigoCredentialsError("Exigo egressProxy.token must be the droplet installation token (dit_…).", ["egressProxy.token"]);
29
+ return bare;
30
+ }
31
+ /** Throws `ExigoCredentialsError` for a URL or token that cannot work. */
32
+ function normalizeEgressProxy(proxy) {
33
+ const url = normalizeUrl(proxy.url);
34
+ return {
35
+ url: url.origin,
36
+ host: url.host,
37
+ token: normalizeToken(proxy.token)
38
+ };
39
+ }
40
+ /** What each answer to CONNECT means, and what to check. */
41
+ function describeProxyRefusal(status, target, proxyHost) {
42
+ const prefix = `Fluid's egress proxy at ${proxyHost} refused the tunnel to ${target} (HTTP ${status})`;
43
+ switch (status) {
44
+ case 407: return `${prefix}: it did not accept the token. Pass the droplet installation token (dit_…) as egressProxy.token.`;
45
+ case 403: return `${prefix}: this droplet may not egress through it, or ${target} is not on its allowlist.`;
46
+ case 502: return `${prefix}: it could not reach Exigo.`;
47
+ case 503: return `${prefix}: it could not verify the token with Fluid or is at capacity. Retry.`;
48
+ case 504: return `${prefix}: its connection to Exigo timed out. Retry.`;
49
+ default: return `${prefix}.`;
50
+ }
51
+ }
52
+ //#endregion
53
+ export { LOOPBACK_HOSTS, describeProxyRefusal, normalizeEgressProxy };
package/dist/rest.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ExigoRestCredentials } from "./credentials.mjs";
2
2
  import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoProxyError, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
3
+ import { ExigoEgressProxy } from "./egress-proxy.mjs";
3
4
 
4
5
  //#region src/rest.d.ts
5
6
  declare const EXIGO_API_VERSION = "3.0";
@@ -21,27 +22,6 @@ interface ExigoRequestOptions {
21
22
  }
22
23
  type ExigoMethodOptions = Omit<ExigoRequestOptions, "body">;
23
24
  type ExigoBodyMethodOptions = ExigoRequestOptions;
24
- /**
25
- * Fluid's egress proxy: a CONNECT-only forward proxy with a static outbound IP
26
- * that Exigo tenants allowlist, for apps on hosts whose egress IPs are not
27
- * Fluid's (Mist apps on Vercel). TLS stays end to end between the app and
28
- * Exigo; the proxy never sees the Exigo credentials.
29
- */
30
- interface ExigoEgressProxy {
31
- /**
32
- * The proxy URL, which Fluid provides to a droplet-linked Mist app as
33
- * `FLUID_EGRESS_PROXY_URL`. Must be https: the CONNECT request carries the
34
- * token outside the tunnel. Plain http is accepted only for a loopback host,
35
- * such as a proxy run locally.
36
- */
37
- readonly url: string;
38
- /**
39
- * The droplet installation token (`dit_…`, the `authentication_token` of the
40
- * install webhook) the proxy authenticates the app with. A `Bearer ` prefix
41
- * is optional.
42
- */
43
- readonly token: string;
44
- }
45
25
  interface ExigoRestClientOptions {
46
26
  /** Per-request timeout. Defaults to {@link DEFAULT_EXIGO_TIMEOUT_MS}. */
47
27
  readonly timeoutMs?: number;
@@ -94,4 +74,4 @@ declare function normalizeExigoBaseUrl(baseUrl: string): string;
94
74
  */
95
75
  declare function createExigoRestClient(credentials: ExigoRestCredentials | null | undefined, options?: ExigoRestClientOptions): ExigoRestClient;
96
76
  //#endregion
97
- export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoBodyMethodOptions, ExigoConnectionError, ExigoCredentialsError, ExigoEgressProxy, ExigoError, ExigoErrorOptions, ExigoHttpMethod, ExigoMethodOptions, ExigoProxyError, ExigoQueryValue, ExigoRequestOptions, ExigoRestClient, ExigoRestClientOptions, type ExigoRestCredentials, ExigoSource, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
77
+ export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoBodyMethodOptions, ExigoConnectionError, ExigoCredentialsError, type ExigoEgressProxy, ExigoError, ExigoErrorOptions, ExigoHttpMethod, ExigoMethodOptions, ExigoProxyError, ExigoQueryValue, ExigoRequestOptions, ExigoRestClient, ExigoRestClientOptions, type ExigoRestCredentials, ExigoSource, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
package/dist/rest.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoProxyError, ExigoTimeoutError } from "./errors.mjs";
2
2
  import { assertExigoRestCredentials } from "./credentials.mjs";
3
+ import { LOOPBACK_HOSTS, describeProxyRefusal, normalizeEgressProxy } from "./egress-proxy.mjs";
3
4
  //#region src/rest.ts
4
5
  /**
5
6
  * `@fluid-app/exigo-connection-sdk/rest`: one way to call Exigo's REST API.
@@ -18,11 +19,6 @@ const EXIGO_API_VERSION = "3.0";
18
19
  const DEFAULT_EXIGO_TIMEOUT_MS = 15e3;
19
20
  const BODY_SNIPPET_LENGTH = 500;
20
21
  const VERSION_SEGMENT = /^\d+\.\d+$/;
21
- const LOOPBACK_HOSTS = new Set([
22
- "localhost",
23
- "127.0.0.1",
24
- "[::1]"
25
- ]);
26
22
  /**
27
23
  * `https://acme-api.exigo.com`, `…/3.0` and `…/3.0/` all become
28
24
  * `https://acme-api.exigo.com/3.0`. A base URL ending in any other version
@@ -137,24 +133,6 @@ function validTimeout(timeoutMs) {
137
133
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) throw new TypeError(`Exigo timeoutMs must be a positive number up to ${MAX_TIMEOUT_MS}, got ${timeoutMs}.`);
138
134
  return timeoutMs;
139
135
  }
140
- /** Same rule as the base URL: the CONNECT request carries the token in clear. */
141
- function normalizeEgressProxyUrl(proxyUrl) {
142
- let url;
143
- try {
144
- url = new URL(proxyUrl.trim());
145
- } catch {
146
- throw new ExigoCredentialsError("Exigo egressProxy.url is not a valid URL.", ["egressProxy.url"]);
147
- }
148
- const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
149
- if (url.protocol !== "https:" && !loopbackHttp) throw new ExigoCredentialsError("Exigo egressProxy.url must be an https URL; the CONNECT request carries the token.", ["egressProxy.url"]);
150
- if (url.username || url.password) throw new ExigoCredentialsError("Exigo egressProxy.url must not carry credentials; pass the token instead.", ["egressProxy.url"]);
151
- return url.origin;
152
- }
153
- function normalizeEgressProxyToken(token) {
154
- const bare = token.replace(/^\s*Bearer(?:\s+|$)/i, "").trim();
155
- if (bare === "" || /\s/.test(bare)) throw new ExigoCredentialsError("Exigo egressProxy.token must be the droplet installation token (dit_…).", ["egressProxy.token"]);
156
- return bare;
157
- }
158
136
  /**
159
137
  * One proxy agent per proxy URL and token, shared by every client that uses
160
138
  * them, so a droplet building a client per request still reuses its tunnels.
@@ -195,17 +173,6 @@ function proxyRefusalStatus(error) {
195
173
  }
196
174
  return null;
197
175
  }
198
- function describeProxyRefusal(status, host, proxyHost) {
199
- const prefix = `Fluid's egress proxy at ${proxyHost} refused the tunnel to ${host} (HTTP ${status})`;
200
- switch (status) {
201
- case 407: return `${prefix}: it did not accept the token. Pass the droplet installation token (dit_…) as egressProxy.token.`;
202
- case 403: return `${prefix}: this droplet may not egress through it, or ${host} is not on its allowlist.`;
203
- case 502: return `${prefix}: it could not reach Exigo.`;
204
- case 503: return `${prefix}: it could not verify the token with Fluid or is at capacity. Retry.`;
205
- case 504: return `${prefix}: its connection to Exigo timed out. Retry.`;
206
- default: return `${prefix}.`;
207
- }
208
- }
209
176
  /**
210
177
  * Creates a client for one set of REST credentials.
211
178
  *
@@ -219,11 +186,8 @@ function createExigoRestClient(credentials, options = {}) {
219
186
  const baseUrl = normalizeExigoBaseUrl(credentials.baseUrl);
220
187
  const host = new URL(baseUrl).host;
221
188
  const authorization = authorizationHeader(credentials);
222
- const egressProxy = options.egressProxy ? {
223
- url: normalizeEgressProxyUrl(options.egressProxy.url),
224
- token: normalizeEgressProxyToken(options.egressProxy.token)
225
- } : null;
226
- const proxyHost = egressProxy ? new URL(egressProxy.url).host : null;
189
+ const egressProxy = options.egressProxy ? normalizeEgressProxy(options.egressProxy) : null;
190
+ const proxyHost = egressProxy?.host ?? null;
227
191
  const secrets = [
228
192
  credentials.password,
229
193
  authorization.replace(/^Basic /, ""),
@@ -0,0 +1,172 @@
1
+ import { ExigoConnectionError, ExigoProxyError } from "./errors.mjs";
2
+ import { describeProxyRefusal } from "./egress-proxy.mjs";
3
+ import * as net from "node:net";
4
+ import * as tls from "node:tls";
5
+ //#region src/sql-tunnel.ts
6
+ /**
7
+ * Opens SQL Server connections through Fluid's egress proxy.
8
+ *
9
+ * tedious, which `mssql` drives, ignores proxy settings but accepts a
10
+ * `connector` that hands it an already-connected socket. This one opens a
11
+ * CONNECT tunnel to the server's host and port and returns the socket once the
12
+ * proxy answers 200; tedious then speaks TDS through it exactly as it would on
13
+ * a direct connection, including the TLS handshake, which the proxy cannot
14
+ * read. Redirects from the server (`routingData`) are tunnelled too, because
15
+ * tedious calls the connector with whatever host and port it is dialling.
16
+ *
17
+ * With `encrypt: true` tedious only starts TLS when the server's PRELOGIN
18
+ * answer offers it; a server, or anything impersonating one, that answers
19
+ * NOT_SUP or OFF would get the login with its password merely obfuscated. The
20
+ * connector sees the raw bytes first, so it reads that answer and hangs up
21
+ * before tedious can send the login unless encryption is ON or REQ.
22
+ */
23
+ /** A proxy's response head is a few hundred bytes; past this it is not one. */
24
+ const MAX_RESPONSE_HEAD_BYTES = 16 * 1024;
25
+ function authority(host, port) {
26
+ return net.isIPv6(host) ? `[${host}]:${port}` : `${host}:${port}`;
27
+ }
28
+ function dialProxy(proxyUrl) {
29
+ const port = Number(proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80));
30
+ const host = proxyUrl.hostname.replace(/^\[(.*)\]$/, "$1");
31
+ if (proxyUrl.protocol !== "https:") return net.connect({
32
+ host,
33
+ port
34
+ });
35
+ return tls.connect({
36
+ host,
37
+ port,
38
+ servername: net.isIP(host) ? void 0 : host
39
+ });
40
+ }
41
+ /**
42
+ * Resolves with the proxy's status once the response head has arrived, and
43
+ * any bytes after it, which belong to the tunnel.
44
+ */
45
+ function readResponseHead(socket, ready, request, signal) {
46
+ return new Promise((resolve, reject) => {
47
+ let buffered = Buffer.alloc(0);
48
+ const cleanup = () => {
49
+ socket.off(ready, onReady);
50
+ socket.off("data", onData);
51
+ socket.off("error", onError);
52
+ socket.off("close", onClose);
53
+ signal.removeEventListener("abort", onAbort);
54
+ };
55
+ const fail = (error) => {
56
+ cleanup();
57
+ socket.destroy();
58
+ reject(error);
59
+ };
60
+ const onReady = () => {
61
+ socket.write(request);
62
+ };
63
+ const onData = (chunk) => {
64
+ buffered = Buffer.concat([buffered, chunk]);
65
+ const end = buffered.indexOf("\r\n\r\n");
66
+ if (end === -1) {
67
+ if (buffered.length > MAX_RESPONSE_HEAD_BYTES) fail(/* @__PURE__ */ new Error("the proxy's response head is too long"));
68
+ return;
69
+ }
70
+ const statusLine = buffered.subarray(0, buffered.indexOf("\r\n")).toString("latin1");
71
+ const match = /^HTTP\/1\.[01] (\d{3})\b/.exec(statusLine);
72
+ if (!match) {
73
+ fail(/* @__PURE__ */ new Error("the proxy did not answer with HTTP"));
74
+ return;
75
+ }
76
+ cleanup();
77
+ socket.pause();
78
+ resolve({
79
+ status: Number(match[1]),
80
+ rest: buffered.subarray(end + 4)
81
+ });
82
+ };
83
+ const onError = (error) => fail(error);
84
+ const onClose = () => fail(/* @__PURE__ */ new Error("the proxy closed the connection before answering"));
85
+ const onAbort = () => fail(signal.reason);
86
+ if (signal.aborted) {
87
+ fail(signal.reason);
88
+ return;
89
+ }
90
+ socket.once(ready, onReady);
91
+ socket.on("data", onData);
92
+ socket.on("error", onError);
93
+ socket.on("close", onClose);
94
+ signal.addEventListener("abort", onAbort, { once: true });
95
+ });
96
+ }
97
+ /** A tedious connector that dials every SQL connection through the proxy. */
98
+ function egressProxyConnector(proxy) {
99
+ const proxyUrl = new URL(proxy.url);
100
+ return async ({ host, port }, _lookup, signal) => {
101
+ const target = authority(host, port);
102
+ const socket = dialProxy(proxyUrl);
103
+ const request = `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\nProxy-Authorization: Bearer ${proxy.token}\r\n\r
104
+ `;
105
+ let head;
106
+ try {
107
+ head = await readResponseHead(socket, proxyUrl.protocol === "https:" ? "secureConnect" : "connect", request, signal);
108
+ } catch (error) {
109
+ if (signal.aborted) throw error;
110
+ const reason = error instanceof Error ? error.message : String(error);
111
+ throw new ExigoConnectionError(`Could not reach Fluid's egress proxy at ${proxy.host}: ${reason}`, "sql", { cause: error });
112
+ }
113
+ if (head.status !== 200) {
114
+ socket.destroy();
115
+ throw new ExigoProxyError(describeProxyRefusal(head.status, target, proxy.host), "sql", { status: head.status });
116
+ }
117
+ refuseUnencryptedPrelogin(socket, target);
118
+ if (head.rest.length > 0) socket.unshift(head.rest);
119
+ return socket;
120
+ };
121
+ }
122
+ const TDS_HEADER_LENGTH = 8;
123
+ const TDS_TABULAR_RESULT = 4;
124
+ const PRELOGIN_ENCRYPTION = 1;
125
+ const PRELOGIN_TERMINATOR = 255;
126
+ const ENCRYPTION_NAMES = {
127
+ 0: "OFF",
128
+ 1: "ON",
129
+ 2: "NOT_SUP",
130
+ 3: "REQ"
131
+ };
132
+ const ENCRYPTED = new Set([1, 3]);
133
+ /** A PRELOGIN answer is tens of bytes; past this it is not one. */
134
+ const MAX_PRELOGIN_BYTES = 4 * 1024;
135
+ /** The ENCRYPTION option of a PRELOGIN answer, or null if there is none. */
136
+ function preloginEncryption(packet) {
137
+ if (packet.length < TDS_HEADER_LENGTH || packet[0] !== TDS_TABULAR_RESULT) return null;
138
+ const payload = packet.subarray(TDS_HEADER_LENGTH);
139
+ for (let index = 0; index + 5 <= payload.length; index += 5) {
140
+ const type = payload[index];
141
+ if (type === PRELOGIN_TERMINATOR) return null;
142
+ if (type !== PRELOGIN_ENCRYPTION) continue;
143
+ const offset = payload.readUInt16BE(index + 1);
144
+ if (payload.readUInt16BE(index + 3) < 1 || offset >= payload.length) return null;
145
+ return payload[offset] ?? null;
146
+ }
147
+ return null;
148
+ }
149
+ function refuseUnencryptedPrelogin(socket, target) {
150
+ let buffered = Buffer.alloc(0);
151
+ const refuse = (detail) => {
152
+ socket.destroy(new ExigoConnectionError(`Exigo's SQL Server at ${target} did not agree to encrypt the connection (${detail}); the login was not sent.`, "sql"));
153
+ };
154
+ const onData = (chunk) => {
155
+ buffered = Buffer.concat([buffered, chunk]);
156
+ if (buffered.length < TDS_HEADER_LENGTH) return;
157
+ const length = buffered.readUInt16BE(2);
158
+ if (length < TDS_HEADER_LENGTH || length > MAX_PRELOGIN_BYTES) {
159
+ socket.off("data", onData);
160
+ refuse("its first answer is not a PRELOGIN response");
161
+ return;
162
+ }
163
+ if (buffered.length < length) return;
164
+ socket.off("data", onData);
165
+ const encryption = preloginEncryption(buffered.subarray(0, length));
166
+ if (encryption === null) refuse("its PRELOGIN response carries no encryption option");
167
+ else if (!ENCRYPTED.has(encryption)) refuse(`PRELOGIN encryption ${ENCRYPTION_NAMES[encryption] ?? encryption}`);
168
+ };
169
+ socket.on("data", onData);
170
+ }
171
+ //#endregion
172
+ export { egressProxyConnector };
package/dist/sql.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ExigoSqlCredentials } from "./credentials.mjs";
2
2
  import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoProxyError, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
3
+ import { ExigoEgressProxy } from "./egress-proxy.mjs";
3
4
  import { ConnectionPool, ConnectionPool as ConnectionPool$1 } from "mssql";
4
5
 
5
6
  //#region src/sql.d.ts
@@ -24,6 +25,14 @@ interface ExigoPoolOptions {
24
25
  * dropped. Defaults to a `console.warn`; the pool replaces the connection.
25
26
  */
26
27
  readonly onError?: (error: ExigoError | Error) => void;
28
+ /**
29
+ * Dials every connection through Fluid's egress proxy, for an app whose
30
+ * outbound IP Exigo does not allowlist. Leave it unset to connect directly,
31
+ * as in local development. Part of the pool's identity: a call with a
32
+ * different proxy or token gets its own pool. Requires `encrypt`, so the
33
+ * login never crosses the proxy in clear.
34
+ */
35
+ readonly egressProxy?: ExigoEgressProxy;
27
36
  }
28
37
  /**
29
38
  * Turns an `mssql` failure into the package's typed errors where Exigo's
@@ -35,8 +44,8 @@ declare function translateExigoSqlError(error: unknown): unknown;
35
44
  * The open pool for these credentials, opening it on first use.
36
45
  *
37
46
  * Pools are keyed by host, port, database and username, and within that by
38
- * password and TLS settings. Concurrent first calls share one connect. A
39
- * failed connect is not cached, so the next call tries again. A rotated
47
+ * password, TLS settings and egress proxy. Concurrent first calls share one
48
+ * connect. A failed connect is not cached, so the next call tries again. A rotated
40
49
  * password opens its own pool without closing the previous one, which is
41
50
  * closed once no call has used it for five minutes. A pool closed directly is
42
51
  * replaced on the next call.
@@ -50,4 +59,4 @@ declare function closeExigoPool(credentials: Pick<ExigoSqlCredentials, "host" |
50
59
  /** Closes every pool this package opened; for shutdown hooks and tests. */
51
60
  declare function closeAllExigoPools(): Promise<void>;
52
61
  //#endregion
53
- export { type ConnectionPool, EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoPoolOptions, ExigoProxyError, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
62
+ export { type ConnectionPool, EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, type ExigoEgressProxy, ExigoError, ExigoErrorOptions, ExigoPoolOptions, ExigoProxyError, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
package/dist/sql.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoProxyError, ExigoTimeoutError } from "./errors.mjs";
2
2
  import { assertExigoSqlCredentials } from "./credentials.mjs";
3
+ import { normalizeEgressProxy } from "./egress-proxy.mjs";
4
+ import { egressProxyConnector } from "./sql-tunnel.mjs";
3
5
  import { createHash } from "node:crypto";
4
6
  //#region src/sql.ts
5
7
  /**
@@ -15,6 +17,9 @@ import { createHash } from "node:crypto";
15
17
  * the first time a pool opens, so an app that only imports `./rest` never
16
18
  * loads it. In Next, list `mssql` in `serverExternalPackages` and set
17
19
  * `export const runtime = "nodejs"` on routes that query.
20
+ *
21
+ * With `egressProxy`, every connection is dialled through Fluid's egress
22
+ * proxy (see `./sql-tunnel`).
18
23
  */
19
24
  const EXIGO_SQL_DEFAULTS = {
20
25
  port: 1433,
@@ -24,9 +29,9 @@ const EXIGO_SQL_DEFAULTS = {
24
29
  requestTimeoutMs: 3e4
25
30
  };
26
31
  /**
27
- * Each database holds one pool per credential fingerprint (password and TLS
28
- * settings). A rotation therefore never closes a pool another request is
29
- * using: requests that resolved the old password keep the old pool, new ones
32
+ * Each database holds one pool per credential fingerprint (password, TLS
33
+ * settings and egress proxy). A rotation therefore never closes a pool
34
+ * another request is using: requests that resolved the old password keep the old pool, new ones
30
35
  * get the new pool, and a rollback finds its pool still there. A pool that
31
36
  * is not its database's most recently used one, and that no request has asked
32
37
  * for in this long, is closed and forgotten. The sweep runs on every call and,
@@ -61,14 +66,15 @@ function poolKey(credentials) {
61
66
  credentials.username
62
67
  ]);
63
68
  }
64
- function fingerprint(credentials) {
69
+ function fingerprint(credentials, proxy) {
65
70
  return createHash("sha256").update(JSON.stringify([
66
71
  credentials.password,
67
72
  credentials.encrypt ?? true,
68
- credentials.trustServerCertificate
73
+ credentials.trustServerCertificate,
74
+ proxy ? [proxy.url, proxy.token] : null
69
75
  ])).digest("hex");
70
76
  }
71
- function toMssqlConfig(credentials, options) {
77
+ function toMssqlConfig(credentials, options, proxy) {
72
78
  return {
73
79
  server: credentials.host.trim(),
74
80
  port: credentials.port ?? EXIGO_SQL_DEFAULTS.port,
@@ -85,7 +91,8 @@ function toMssqlConfig(credentials, options) {
85
91
  options: {
86
92
  encrypt: credentials.encrypt ?? true,
87
93
  trustServerCertificate: credentials.trustServerCertificate,
88
- enableArithAbort: true
94
+ enableArithAbort: true,
95
+ ...proxy ? { connector: egressProxyConnector(proxy) } : {}
89
96
  }
90
97
  };
91
98
  }
@@ -129,7 +136,8 @@ function errorChain(error) {
129
136
  * such as a syntax error in a droplet's query, is returned unchanged.
130
137
  */
131
138
  function translateExigoSqlError(error) {
132
- if (error instanceof ExigoError) return error;
139
+ const own = ownErrorIn(error);
140
+ if (own !== null) return own;
133
141
  const { codes, text } = errorChain(error);
134
142
  const code = error !== null && typeof error === "object" ? error.code : void 0;
135
143
  const name = error instanceof Error ? error.name : "";
@@ -145,8 +153,29 @@ function translateExigoSqlError(error) {
145
153
  if (name === "ConnectionError" || code === "ESOCKET" || code === "ECONNCLOSED") return new ExigoConnectionError(`Could not connect to Exigo's SQL Server: ${raw}`, "sql", options);
146
154
  return error;
147
155
  }
148
- async function openPool(credentials, options) {
149
- const pool = new (await (loadMssql())).ConnectionPool(toMssqlConfig(credentials, options));
156
+ /** The first `ExigoError` inside mssql's and tedious's wrapping, if any. */
157
+ function ownErrorIn(error) {
158
+ const seen = /* @__PURE__ */ new Set();
159
+ const visit = (value, depth) => {
160
+ if (value === null || typeof value !== "object" || seen.has(value) || depth > 5) return null;
161
+ seen.add(value);
162
+ if (value instanceof ExigoError) return value;
163
+ const record = value;
164
+ const inner = [
165
+ record.originalError,
166
+ record.cause,
167
+ ...Array.isArray(record.errors) ? record.errors : []
168
+ ];
169
+ for (const candidate of inner) {
170
+ const found = visit(candidate, depth + 1);
171
+ if (found !== null) return found;
172
+ }
173
+ return null;
174
+ };
175
+ return visit(error, 0);
176
+ }
177
+ async function openPool(credentials, options, proxy) {
178
+ const pool = new (await (loadMssql())).ConnectionPool(toMssqlConfig(credentials, options, proxy));
150
179
  const onError = options.onError ?? ((error) => console.warn(`[@fluid-app/exigo-connection-sdk] ${error.message}`));
151
180
  pool.on("error", (error) => {
152
181
  const translated = translateExigoSqlError(error);
@@ -166,8 +195,8 @@ function isUsable(pool) {
166
195
  * The open pool for these credentials, opening it on first use.
167
196
  *
168
197
  * Pools are keyed by host, port, database and username, and within that by
169
- * password and TLS settings. Concurrent first calls share one connect. A
170
- * failed connect is not cached, so the next call tries again. A rotated
198
+ * password, TLS settings and egress proxy. Concurrent first calls share one
199
+ * connect. A failed connect is not cached, so the next call tries again. A rotated
171
200
  * password opens its own pool without closing the previous one, which is
172
201
  * closed once no call has used it for five minutes. A pool closed directly is
173
202
  * replaced on the next call.
@@ -175,9 +204,11 @@ function isUsable(pool) {
175
204
  async function getExigoPool(credentials, options = {}) {
176
205
  if (!credentials) throw new ExigoCredentialsError("No Exigo SQL credentials were provided.", ["sql"]);
177
206
  assertExigoSqlCredentials(credentials);
207
+ const proxy = options.egressProxy ? normalizeEgressProxy(options.egressProxy) : null;
208
+ if (proxy && credentials.encrypt === false) throw new ExigoCredentialsError("Exigo sql.encrypt cannot be false with egressProxy; the login would cross the proxy unencrypted.", ["sql.encrypt"]);
178
209
  const pools = cache();
179
210
  const identity = poolKey(credentials);
180
- const current = fingerprint(credentials);
211
+ const current = fingerprint(credentials, proxy);
181
212
  const group = () => {
182
213
  let entries = pools.get(identity);
183
214
  if (entries === void 0) {
@@ -202,7 +233,7 @@ async function getExigoPool(credentials, options = {}) {
202
233
  }
203
234
  }
204
235
  const entry = {
205
- pool: openPool(credentials, options),
236
+ pool: openPool(credentials, options, proxy),
206
237
  lastUsed: Date.now()
207
238
  };
208
239
  const entries = group();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/exigo-connection-sdk",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Exigo REST client, SQL pool and credential interface for Fluid droplets and Mist apps",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {