Node client SDK
Consume the platform from Node — headless OIDC auth, a retrying JSON client that resolves named services, and the five access gates.
The platform, consumed from Node
The Node client SDK is how a JavaScript or TypeScript program consumes the SynaptaGrid platform — logging in a user, calling any platform service over authenticated JSON, and applying the five access gates. It is one of four client SDKs (Go, Rust, PHP, Node) that share the same shape in every language; the SDKs overview has the family table and the shared concepts. This page is the dedicated Node reference.
It carries four pieces, one per module:
- A headless OIDC token manager — runs AuthN's 3-step PKCE login (no IdP page), caches the access token until just before it expires, and re-logs in on expiry.
- An authenticated JSON client — resolves a named service through a URL map, attaches the bearer, and retries transient failures and 5xx with exponential backoff (never a 4xx).
- Typed envelopes — decodes the platform's standard
BaseResponseon success and throws a typedApiErrorcarrying the machine-readableerror_codeon any>= 400. - The five gates —
authenticate,requirePermission,requireActiveSubscription,requireFeature,enforceQuota, ported natively and failing closed.
This SDK is consume-only. It deliberately omits the app-building runtime — no app factory, tenant middleware, outbox, or scoped CRUD. That lives in the app SDKs.
Do not confuse it with @egav/app-sdk. The TypeScript app SDK (@djokodonev/egav-app-sdk, aliased @egav/app-sdk) is a different package for building React frontends — manifest parsing, tenant context, field widgets, an ApiClient. This page is about @egav/sdk (@djokodonev/egav-sdk), the standalone client for calling the platform from any Node program.
Modules and exports
The package is ESM ("type": "module") with a subpath export per concern. Import the whole surface from @egav/sdk, or a single module from its subpath.
| Import | Exports | Responsibility |
|---|---|---|
@egav/sdk | all of the below | Re-exports the full public surface. |
@egav/sdk/auth | OIDCHeadlessTokenManager, StaticTokenSource | Headless OIDC login with PKCE; token caching and single-flight re-login. |
@egav/sdk/client | Client | Authenticated JSON requests to a named service, with retry/backoff. |
@egav/sdk/envelope | decode, ApiError | Decode BaseResponse; throw ApiError on >= 400. |
@egav/sdk/gates | Gates and the typed gate errors | The five access gates, failing closed. |
@egav/sdk/http | requestRaw | Low-level node:http / node:https helper (used by the modules above). |
Install and distribution
The real published name is @djokodonev/egav-sdk, consumed through the friendlier alias @egav/sdk. It has zero runtime dependencies — it is built only on Node core modules (node:http, node:https, node:crypto) — and requires Node 24+.
It is published to GitHub Packages — a private, restricted registry. Reference it through an npm alias so imports still read @egav/sdk while the resolved package is @djokodonev/egav-sdk. The @djokodonev scope needs an .npmrc routing it to the registry with an auth token carrying read:packages:
@djokodonev:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}{
"dependencies": {
"@egav/sdk": "npm:@djokodonev/egav-sdk@^0.1.0"
}
}Distribution status: this SDK is published to GitHub Packages, a restricted registry — the install above needs aread:packagestoken, and a barenpm installfrom the public index will not find it. See the honesty note in the SDKs overview.
A full worked example
Construct a token source, hand it to a Client along with a service-URL map and any default headers, then call a service. client.get(...) returns the decoded BaseResponse; its .data is your payload.
// package.json: "dependencies": { "@egav/sdk": "npm:@djokodonev/egav-sdk@^0.1.0" }
import { OIDCHeadlessTokenManager, Client } from '@egav/sdk';
const tokenSource = new OIDCHeadlessTokenManager({
authNBaseURL: 'https://authn-api.your-domain.example:5209',
clientId: 'public_web_67a894b8cb0e', // a registered public OIDC client
redirectUri: 'https://your-domain.example:3200/callback',
scope: 'openid profile email', // optional; default is the same
email: process.env.EGAV_TEST_EMAIL,
password: process.env.EGAV_TEST_PASSWORD,
insecureSkipVerify: true, // local dev self-signed certs only
});
const client = new Client({
serviceURLs: {
authn: 'https://authn-api.your-domain.example:5209',
'control-plane': 'https://cp-api.your-domain.example:5207',
authz: 'https://authz-api.your-domain.example:5206',
},
tokenSource,
insecureSkipVerify: true, // local dev self-signed certs only
defaultHeaders: { 'X-App-Code': 'demo' },
});
const res = await client.get('control-plane', '/v1/control-plane/portal/me');
console.log(res.data);The full response schema of /portal/me — and every other way to read the current user, their organization, and other members — is documented in Users & organizations.
The token manager
new OIDCHeadlessTokenManager(cfg) runs AuthN's headless OIDC flow with PKCE — the platform has no plain ROPC endpoint, so login is three steps: authorize/headless returns a session_id, headless/credentials submits the email/password and returns a code, and token exchanges that code (with the PKCE verifier) for the access token. The user never sees an IdP page — see Auth & the five gates.
The single method callers use is await tokenSource.token(). It returns a cached access token while one is valid, and re-runs the login only when the token is within expirySkewMs (default 30s) of expires_in. Concurrent token() calls are coalesced into a single in-flight login, so a burst of requests triggers one login, not many. The password is never logged. An account with MFA enabled surfaces an error (mfa_required is not supported by the headless manager).
| Config key | Required | Notes |
|---|---|---|
authNBaseURL | yes | AuthN base URL. |
clientId / redirectUri | yes | Must match a registered public OIDC client. |
email / password | yes | Credentials for the headless login. |
scope | no | Default openid profile email. Add offline_access for a refresh token (the manager otherwise just re-logs in on expiry). |
expirySkewMs | no | Re-login this long before expiry. Default 30000. |
insecureSkipVerify / agent | no | TLS skip-verify or an explicit https.Agent for local self-signed certs. |
StaticTokenSource is the alternative — wrap a token you already hold (for example one forwarded from an inbound request) with new StaticTokenSource(accessToken). Both satisfy the same { token(): Promise<string> } contract the Client and gates consume.
The Client
new Client(cfg) takes a serviceURLs map (app code → base URL) and resolves the target by its short name — client.get('control-plane', ...) looks up control-plane in the map and errors if the app is not registered there. A tokenSource supplies the bearer; defaultHeaders (e.g. X-App-Code, X-App-Instance-Guid) are sent on every request.
Three request methods:
get(app, path)— GET, decode the envelope, return theBaseResponse(throwsApiErroron>= 400).post(app, path, body)— POST a JSON body, same decoding contract.do(app, method, path, body)— the raw response, for callers that want to decode themselves.
Retries are deliberate and conservative: transient transport failures and 5xx are retried with exponential backoff (default 2 retries — 3 attempts total — starting at 200ms and doubling). A 4xx is never retried — it is a client error, returned immediately for you to surface. On a 2xx the payload is decoded into the standard envelope; the shapes are the platform's canonical ones:
BaseResponse={ message, success, status, data, meta }, wheremetaon a list is{ total, page, per_page, total_pages }.- On
>= 400the SDK throwsApiError, carryingstatusCode, the platform's machine-readableerrorCode, and the sanitizedapiMessage— so you can branch on either the HTTP status or the platform error code. It never leaks raw server exception text beyond the already-sanitizedmessage.
The five gates in Node
new Gates(cfg) ports the platform's access model so a Node app protects an endpoint exactly as a Python one does. The five gates run in order; each fails closed — an unreachable Control Plane or AuthZ raises Unavailable (503) rather than default-allowing. The method names in Node are camelCase (authenticate / requirePermission / requireActiveSubscription / requireFeature / enforceQuota), matching the same order and status contract as the Go, Rust, and PHP clients.
import {
Gates, Unauthenticated, PermissionDenied, SubscriptionInactive,
FeatureDisabled, QuotaExceeded, Unavailable,
} from '@egav/sdk/gates';
const gates = new Gates({
controlPlaneURL: 'https://cp-api.your-domain.example:5207',
authzURL: 'https://authz-api.your-domain.example:5206',
cpApiKey: process.env.CONTROL_PLANE_API_KEY, // gates 3-5, CP /internal/*
authzApiKey: process.env.AUTHZ_API_KEY, // gate 2, AuthZ /internal/*
});
// One protected endpoint applies all five, in order. Each fails closed.
const identity = await gates.authenticate(userBearer); // 1 -> 401 / 503
await gates.requirePermission(identity, 'project.create'); // 2 -> 403 / 503
const ent = await gates.entitlements(identity.orgGuid); // one CP fetch for 3-5
gates.requireActiveSubscription(ent); // 3 -> 402
gates.requireFeature(ent, 'projects'); // 4 -> 403
gates.enforceQuota(ent, 'max_projects', currentCount); // 5 -> 429The method surface and its status contract:
| Gate | Method | Fetches | Fails with |
|---|---|---|---|
| 1 — auth | authenticate(userBearer) → { userGuid, orgGuid } | CP /portal/me with the user bearer | Unauthenticated 401, Unavailable 503 |
| 2 — permission | requirePermission(identity, code, resourceType = 'app') | AuthZ /internal/authorize with the app key | PermissionDenied 403, Unavailable 503 |
| — | entitlements(orgGuid) → Entitlements | CP /internal/.../effective-features (one call for gates 3–5) | Unavailable 503 |
| 3 — subscription | requireActiveSubscription(ent) | pure over the fetched entitlements | SubscriptionInactive 402 |
| 4 — feature | requireFeature(ent, code) | pure over entitlements | FeatureDisabled 403 |
| 5 — quota | enforceQuota(ent, code, current, cost = 1) → resolved limit | pure over entitlements | QuotaExceeded 429, Unavailable 503 on a misconfigured limit |
Every gate error extends GateError and carries an httpStatus, so a handler maps a caught error straight to a response. Gates 3–5 are pure functions over the Entitlements you fetch once with entitlements(...) — a single CP round-trip backs all three, rather than one call per gate.
Two credentials, the correct trust model — no shortcut. The end-user bearer is used only for gate 1 (against CP's portal surface). Gates 2–5 hit /internal/* service-to-service endpoints and use the app's service x-api-key — AuthZ's inbound key for gate 2, the Control Plane's for gates 3–5. The user bearer is never sent to an /internal/* endpoint, and a service key is never sent to a portal endpoint. This mirrors the Python composition described in Auth & the five gates.