@fluid-app/exigo-connection-sdk
v0.1.84
Published
Exigo REST client, SQL pool and credential interface for Fluid droplets and Mist apps
Readme
@fluid-app/exigo-connection-sdk
One way for Fluid droplets and Mist apps to talk to Exigo: a REST client, a cached SQL pool, a credential interface and typed errors. The package owns connection, auth and errors only. Business queries stay in each app.
Install
Public on npmjs:
pnpm add @fluid-app/exigo-connection-sdk
# Only for apps that query the SQL replica:
pnpm add mssql
pnpm add -D @types/mssql
# Only for apps that reach Exigo through Fluid's egress proxy:
pnpm add undici| Import | Provides | Needs mssql |
| -------------------------------------- | -------------------------------------------------- | ------------- |
| @fluid-app/exigo-connection-sdk | defineExigoCredentials, credential types, errors | no |
| @fluid-app/exigo-connection-sdk/rest | createExigoRestClient, errors | no |
| @fluid-app/exigo-connection-sdk/sql | getExigoPool, closeExigoPool, errors | yes |
Runs on Node 20+: Cloud Run, and Vercel's Node runtime. Not on Edge.
1. Credentials, once per app
The package reads no environment variables and stores nothing. The app says
where credentials come from; a missing field throws ExigoCredentialsError
naming it.
// src/lib/exigo.ts — a multi-tenant droplet
import { defineExigoCredentials } from "@fluid-app/exigo-connection-sdk";
export const resolveExigoCredentials = defineExigoCredentials(
async (companyId: string) => {
const c = await prisma.credentials.findUniqueOrThrow({
where: { companyId },
});
return {
rest: {
baseUrl: c.exigoApiBaseUrl,
username: c.exigoApiUser,
password: c.exigoApiPassword,
company: c.exigoCompanyName,
},
sql: {
host: c.exigoDbHost,
database: c.exigoDbName,
username: c.exigoDbUsername,
password: c.exigoDbPassword,
// Required, no default. true skips server certificate verification,
// which Exigo's replicas need: they present a certificate that does
// not verify against the host (e.g. SQL Server's self-signed one).
trustServerCertificate: true,
},
};
},
);A single-tenant Mist app returns its env vars from the same function instead. The resolver runs on every call, so cache in the app if the lookup is costly.
2. REST
import {
createExigoRestClient,
ExigoAuthError,
ExigoTimeoutError,
} from "@fluid-app/exigo-connection-sdk/rest";
const { rest } = await resolveExigoCredentials(company.id);
const exigo = createExigoRestClient(rest);
try {
const account = await exigo.get<PointAccount>(
"/customers/{id}/pointaccounts/{accountId}",
{ params: { id: customerId, accountId: pointAccountId } },
);
} catch (error) {
if (error instanceof ExigoAuthError) {
/* wrong credentials: tell the admin */
}
if (error instanceof ExigoTimeoutError) {
/* retry, or fail the callback fast */
}
throw error;
}The client:
- adds
/3.0to the base URL, and does not double it when the stored URL already has it; a base URL ending in another version, such as/2.0, throwsExigoCredentialsErrorrather than pinning the client to it; - requires an
httpsbase URL, since every request carries the password (httponly forlocalhost, for a local stub); - sends Basic auth as
username@company; - times out after 15 s by default (
timeoutMson the client or per call, at most 2,147,483,647 ms, the mostsetTimeoutcan hold); - resolves to the parsed JSON, or
undefinedfor an empty body; - throws
ExigoApiErrorfor a non-2xx status, and for a 200 whose body is an HTML page or not JSON, rather than handing the page back as data; the password and Basic token are redacted from its message andbodySnippet; - refuses a path that is an absolute URL, so the credentials only ever go to
the configured host, and a path that starts with an API version (
/3.0/...), since the client adds it; - refuses a path param that is empty,
.or.., which would otherwise resolve to a different endpoint than the path names.
get, delete take params, query, headers, timeoutMs and signal;
post, put, patch also take a body, sent as JSON.
Through Fluid's egress proxy (Mist apps)
Exigo allowlists callers by source IP, and a Mist app on Vercel has no fixed
outbound IP. Fluid's egress proxy (services/egress-proxy) lends it one: a
CONNECT-only forward proxy whose static IP Exigo tenants allowlist. TLS stays
end to end between the app and Exigo, so the proxy never sees the Exigo
credentials.
pnpm add undiciconst exigo = createExigoRestClient(rest, {
egressProxy: process.env.FLUID_EGRESS_PROXY_URL
? {
url: process.env.FLUID_EGRESS_PROXY_URL,
// The install webhook's authentication_token (dit_…).
token: installation.authenticationToken,
}
: undefined,
});- Fluid sets
FLUID_EGRESS_PROXY_URLon a droplet-linked Mist app when its environment has a proxy.fluid mist env pullleaves it out on purpose: the proxy refuses callers off Fluid's platform, so local runs call Exigo directly, and the code above does that when the variable is unset. - The proxy authenticates the droplet installation token, and lets through only droplets and Exigo hosts on its own allowlists.
- The URL must be
https(httponly for a loopback proxy run locally), since the CONNECT request carries the token outside the tunnel. - A refusal throws
ExigoProxyError, a subclass ofExigoConnectionErrorwhosestatusis the proxy's answer: 407 for a token it does not accept, 403 for a droplet or host it does not let through, 502 or 504 when it could not reach Exigo, 503 when it could not verify the token or is at capacity. - Clients built with the same proxy URL and token share one agent, so a client per request still reuses open tunnels.
- SQL goes through the same proxy; see SQL through the egress proxy.
3. SQL
import { getExigoPool } from "@fluid-app/exigo-connection-sdk/sql";
const { sql: creds } = await resolveExigoCredentials(company.id);
const pool = await getExigoPool(creds);
const { recordset } = await pool
.request()
.input("email", email)
.query("SELECT CustomerID FROM Customers WHERE Email = @email");getExigoPool returns an mssql ConnectionPool, cached per host, port,
database and username, and within that per password. Repeated calls reuse
it, concurrent first calls share one connect, and a failed connect is not
cached. A rotated password opens its own pool without closing the previous
one, so requests in flight across a rotation keep working and a rollback
connects at once; the previous pool is closed once nothing has asked for it
in five minutes, and a database's current pool once nothing has asked for it
in 30. Pools are small (4 connections, closed after 10 s idle) because every running instance opens its own. Pass
{ max, idleTimeoutMs, connectTimeoutMs, requestTimeoutMs } as the second
argument to change that; options apply when the pool opens.
For typed parameters, import them from mssql itself
(import sql from "mssql"; request.input("id", sql.Int, id)).
Connect failures arrive as ExigoAuthError, ExigoTimeoutError or
ExigoConnectionError, with messages that say what to check. Errors from the
app's own queries are left alone; pass one through translateExigoSqlError to
map a timeout or a dropped connection the same way. A query error (RequestError)
comes back unchanged unless its code is a transport one (ETIMEOUT,
ECONNCLOSED, ESOCKET), so a bad table name is never reported as a network
failure.
Close pools on shutdown:
import { closeAllExigoPools } from "@fluid-app/exigo-connection-sdk/sql";
process.once("SIGTERM", () => void closeAllExigoPools());In Next, keep mssql out of the bundle and off Edge:
// next.config.ts
export default { serverExternalPackages: ["mssql"] };
// every route that queries
export const runtime = "nodejs";Through Fluid's egress proxy
The same proxy as for REST, for the same reason: Exigo allowlists the replica
by source IP. mssql ignores proxy settings, so the pool hands tedious a
connector that opens a CONNECT tunnel to the replica's host and port; TDS,
including its TLS handshake, then runs through the tunnel unchanged. No extra
package is needed.
const pool = await getExigoPool(creds, {
egressProxy: process.env.FLUID_EGRESS_PROXY_URL
? {
url: process.env.FLUID_EGRESS_PROXY_URL,
token: installation.authenticationToken, // dit_…
}
: undefined,
});- The proxy must allow the replica's host and port (1433), and Exigo must allowlist the proxy's IP on the SQL side.
- The proxy and token are part of the pool's identity: a call with a different one gets its own pool.
- Encryption is required through the proxy.
encrypt: falseis refused (ExigoCredentialsError). And because SQL Server negotiates TLS (tedious skips it, sending the login with the password merely obfuscated, when the server's PRELOGIN answer does not offer encryption), the connector reads that answer and hangs up before the login unless encryption isONorREQ, throwingExigoConnectionError. Direct connections are not checked this way. - Refusals throw
ExigoProxyErrorwithsource: "sql"; a proxy that cannot be reached throwsExigoConnectionErrornaming it. - Known gap: the connection is encrypted, but with
trustServerCertificate: truenothing proves the other end of the TLS is Exigo. The proxy reads nothing inside the tunnel, yet anything between the proxy and Exigo that impersonates the replica could read the login. Direct connections carry the same gap. Closing it needs a certificate on Exigo's side that can be verified.
Errors
Every error extends ExigoError. None of them carries a password or an
Authorization header.
| Error | When |
| ----------------------- | ----------------------------------------------------- |
| ExigoCredentialsError | a field is missing or invalid (fields names them) |
| ExigoAuthError | REST 401/403, or a SQL login failure |
| ExigoTimeoutError | a REST call, SQL connect or SQL query ran too long |
| ExigoConnectionError | DNS, refused connection, TLS, network |
| ExigoProxyError | the egress proxy refused the tunnel (status) |
| ExigoApiError | REST answered with an error or a body that isn't JSON |
source is "rest" or "sql" on the auth, timeout and connection errors.
Releasing
Listed in .github/workflows/frontend-publish-rep-packages.yml: a merge to
main that changes this package publishes it to npm, bumping the patch
version when the current one is already published.
