Skip to main content

Documentation

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 BaseResponse on success and throws a typed ApiError carrying the machine-readable error_code on 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.

ImportExportsResponsibility
@egav/sdkall of the belowRe-exports the full public surface.
@egav/sdk/authOIDCHeadlessTokenManager, StaticTokenSourceHeadless OIDC login with PKCE; token caching and single-flight re-login.
@egav/sdk/clientClientAuthenticated JSON requests to a named service, with retry/backoff.
@egav/sdk/envelopedecode, ApiErrorDecode BaseResponse; throw ApiError on >= 400.
@egav/sdk/gatesGates and the typed gate errorsThe five access gates, failing closed.
@egav/sdk/httprequestRawLow-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:

.npmrcini
@djokodonev:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}
package.jsonjson
{
  "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 a read:packages token, and a bare npm install from 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.

consume.mjsjavascript
// 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 keyRequiredNotes
authNBaseURLyesAuthN base URL.
clientId / redirectUriyesMust match a registered public OIDC client.
email / passwordyesCredentials for the headless login.
scopenoDefault openid profile email. Add offline_access for a refresh token (the manager otherwise just re-logs in on expiry).
expirySkewMsnoRe-login this long before expiry. Default 30000.
insecureSkipVerify / agentnoTLS 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 the BaseResponse (throws ApiError on >= 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 }, where meta on a list is { total, page, per_page, total_pages }.
  • On >= 400 the SDK throws ApiError, carrying statusCode, the platform's machine-readable errorCode, and the sanitized apiMessage — so you can branch on either the HTTP status or the platform error code. It never leaks raw server exception text beyond the already-sanitized message.

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.

gates.mjsjavascript
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  -> 429

The method surface and its status contract:

GateMethodFetchesFails with
1 — authauthenticate(userBearer) → { userGuid, orgGuid }CP /portal/me with the user bearerUnauthenticated 401, Unavailable 503
2 — permissionrequirePermission(identity, code, resourceType = 'app')AuthZ /internal/authorize with the app keyPermissionDenied 403, Unavailable 503
—entitlements(orgGuid) → EntitlementsCP /internal/.../effective-features (one call for gates 3–5)Unavailable 503
3 — subscriptionrequireActiveSubscription(ent)pure over the fetched entitlementsSubscriptionInactive 402
4 — featurerequireFeature(ent, code)pure over entitlementsFeatureDisabled 403
5 — quotaenforceQuota(ent, code, current, cost = 1) → resolved limitpure over entitlementsQuotaExceeded 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.