xOpat Server API README
This document describes the current server-side API and runtime model for the xOpat server counterpart.
Overview
The server consists of two main entrypoints:
index.jscluster-index.js
index.js
Run this for a single-process server.
Use it when: - developing locally - debugging - running a simple deployment - using only one Node process
cluster-index.js
Run this for a multi-process deployment.
Use it when: - deploying to production on a multi-core machine - you want better throughput and resilience - you want multiple worker processes behind one master process
High-level architecture
The server provides:
- normal HTTP serving
- generic proxy/auth support (see server env.json config)
- RPC execution for plugin/module server methods
- optional auth verification per RPC context
- runtime protections:
- request size limits
- concurrency limits
- timeouts
- circuit breakers
- optional worker isolation
Server-side method discovery
The runtime discovers server methods from files like:
*.server.js*.server.mjs*.server.ts
These files can exist in plugins or modules.
Named exports from these files are exposed as RPC-callable methods.
Example
export async function getChatMessages(ctx, input) {
return { ok: true, input };
}
export const policy = {
getChatMessages: {
auth: {
public: false,
requireSession: true
},
runtime: {
timeoutMs: 5000
}
}
};
RPC transport API
Endpoint shape
Module RPC POST /__rpc/module/:moduleId/:method
Plugin RPC POST /__rpc/plugin/:pluginId/:method
Request body
{
"args": [
{ "foo": "bar" }
],
"viewerId": "optional-viewer-id",
"contextId": "optional-auth-context-id"
}
Successful response:
Error response:Browser-side API
The browser gets window.xserver from the server bootstrap.
Available scopes
window.xserver.module[moduleId]
window.xserver.plugin[pluginId]
Example
This should normally be wrapped by the xOpat element helper:
Instead of doing all this manually, we can elevateXOpatElement.server().
The intended high-level API is:
or:
or:
The underlying transport uses HttpClient, so make hard use of contextualized http clients.
Default client
By default the server call uses APPLICATION_CONTEXT.httpClient.
A caller may override the client if needed.
Auth payload attachment is fully controlled by the chosen HttpClient.
Auth model
Security is provided by: - session validation - CSRF validation
These are not configured as named verifiers, and these are injected automatically by default.
Method auth policy - atop of verifying the request comes from the viewer itself, we can check whether a given user can use the API, we can require login against certain services.
Each method may define:
auth: {
public?: boolean | ((ctx) => boolean); //default false
requireSession?: boolean; //default true
}
Meaning
public: false
The method is protected. The server consults the session, the RPC verifier context, or both — see the decision matrix below.
public: true
The method is public and skips both session and verifier checks. Anyone who can reach the endpoint can call the method.
requireSession: true
A normal xOpat session is required. The request must carry a valid session
cookie and a matching X-XOPAT-CSRF header.
requireSession: false
No session is required. The server logs a one-shot warning when an endpoint opts out. Because no session implies no CSRF, you must pair this with an RPC verifier — see the matrix.
Decision matrix (server-side)
For each call the runtime evaluates auth.public, requireSession, and the
resolved verifier context. The outcome:
public |
requireSession |
Verifier context | Verifier entries | Result |
|---|---|---|---|---|
true |
— | — | — | Accepted (no checks) |
false |
true |
any | any | Session + CSRF (+ verifier if present); all must pass |
false |
false |
has verifiers |
≥ 1 | Verifier only (e.g. raw JWT calls) |
false |
false |
{ enabled: false } |
— | Accepted — explicit operator opt-out |
false |
false |
empty {} / missing |
— | Rejected — RPC_AUTH_NO_VERIFIERS / RPC_AUTH_NOT_CONFIGURED |
The last row is the fail-closed default. The bypass class was: an endpoint
opting out of session (requireSession: false) plus an empty or absent
rpcVerifiers entry would silently pass. Fail-closed is now the default.
The operator opts back in explicitly by setting enabled: false on the
verifier-context entry — leaving the entry empty is no longer accepted as
"no auth needed", because that exact misconfiguration is what made the
original bypass invisible.
Configuring RPC verifiers
Verifiers live under server.secure.rpcVerifiers (the legacy key
server.secure.rpcAuth is still recognised as an alias):
{
"server": {
"secure": {
"rpcVerifiers": {
"default": {},
"my-service": {
"verifiers": {
"jwt": {
"secretEnv": "<% XOPAT_JWT_SECRET %>",
"issuer": "https://issuer.example",
"audience": "xopat"
}
},
"mode": "all"
},
"internal-only": {
"enabled": false
}
}
}
}
}
Context resolution
The client picks the verifier context via the contextId field on the RPC
request body. The runtime then looks it up against
server.secure.rpcVerifiers:
- If
contextIdis a string andrpcVerifiershas an own property by that name, that entry is used. - Otherwise the
defaultentry (own property only) is used. - Otherwise the verifier context is empty — see the decision matrix.
The own-property requirement matters: a naive lookup would let a client send
contextId: "__proto__" and reach Object.prototype, which has no
verifiers and was previously treated as "no auth required". The runtime now
uses Object.prototype.hasOwnProperty.call(...) to block that bypass.
default: {}is not public access. An empty entry exists but configures no verifiers. WithrequireSession: truethis means "session-only"; withrequireSession: falseit means "no verifier configured", and the runtime rejects the call.
Explicit opt-out
An entry shaped { "enabled": false } is treated as "this context disables
verifier checks intentionally". It is the only way to mark a non-public
endpoint as accepting requests without verifier (e.g. internal-only routes
gated by network ACL). Use sparingly — it's the moral equivalent of
public: true once the call passes session checks.
Verifier mode
mode: "all"
All configured verifiers must pass. This is the default.
mode: "any"
At least one configured verifier must pass.
If only one verifier is configured, the mode makes no practical difference.
If the entry has verifiers: {} (or no verifiers at all) the runtime
defers to the session check. With requireSession: true the call still
goes through on a valid session. With requireSession: false the runtime
rejects the call — empty/absent verifier entries are no longer treated as
implicit "no auth needed". Set enabled: false if you really want a
no-verifier, no-session route (e.g. an internal-only RPC fronted by a
network ACL).
How to make a method "auth-less"
There are three legitimate ways to expose a method without bothering with JWT/RPC verifiers, depending on what "auth-less" should mean for your use case:
-
Truly public — anybody on the network can call it.
Skip session, CSRF and verifier checks. Suitable only for endpoints that leak nothing and have no side effects. -
Session-only — the call must come from a logged-in viewer tab. This is the default; you can leave
The runtime enforces the xOpat session cookie +authoff entirely.X-XOPAT-CSRF. NorpcVerifiersconfiguration is needed. -
Verifier-only — for service-to-service traffic that has a JWT but no browser session.
You must pair this with aexport const policy = { ingestExternalEvent: { auth: { public: false, requireSession: false }, }, } as const;rpcVerifiers.<contextId>entry that has real verifiers in it. For an internal-only no-verifier route, opt out explicitly: An emptydefault: {}(or nodefaultat all) is rejected — that was the original silent-bypass shape and is the failure mode the fail-closed guard is named after.
Note on Proxy auth configuration
Proxy auth is configured separately from RPC auth, under
server.secure.proxy.<alias>. Proxy verifier configuration uses the same
verifier maps but is unrelated to the RPC decision matrix above.
Outbound HTTP — SSRF guard
Any *.server.{ts,js,mjs} file can reach a small server-level outbound-HTTP
guard via globalThis.XOPAT_SERVER. Use it instead of raw fetch whenever
the URL is operator- or user-influenced — provider registration, webhooks,
custom proxies, model discovery, etc.
const XS = globalThis.XOPAT_SERVER;
// Validate only — returns the parsed URL or throws SsrfBlockedError.
const url = await XS.validateUpstreamUrl(config.baseUrl);
// Fetch with: scheme allowlist (http/https), private/loopback/link-local/
// CGNAT/multicast block (IPv4 + IPv6), redirect: "manual" enforced, and
// a clear error on any 3xx so attacker-controlled hosts can't chain into
// private space.
const res = await XS.safeFetch(url.toString(), {
method: "GET",
headers: { ... },
signal: ctx?.signal,
});
What the guard does not do:
- Vet redirects performed inside third-party SDKs that bring their own
fetch (e.g. handing a baseURL to the Vercel AI SDK). Vet the baseURL with
validateUpstreamUrlbefore constructing the SDK client; once the SDK takes over, its internal fetches are trusted. - Pin DNS between validation and the actual fetch. The TOCTOU window is
small and the upstream is typically operator-configured. A custom
dispatcher (e.g.
undiciwithlookup) or fetching by literal IP is required to close that gap.
SsrfBlockedError (also exposed on XS) has code === "SSRF_BLOCKED" so
callers can distinguish guard rejections from upstream errors.
Runtime policy API
Each RPC method may optionally define a runtime section.
runtime: {
timeoutMs: 5000,
maxBodyBytes: 262144,
maxConcurrency: 20,
queueLimit: 100,
isolation: "worker",
circuitBreaker: {
key: "cerit-chat",
failureThreshold: 5,
resetAfterMs: 30000
}
}
Runtime fields
timeoutMs - Maximum execution time for the method. If exceeded:
-
the method fails
-
timeout is logged
-
worker/process may be terminated if isolated
maxBodyBytes - Maximum allowed request body size for this method.
If exceeded:
request is rejected
server returns 413 Payload Too Large
maxConcurrency
Maximum number of active calls for this method at once.
queueLimit
Maximum number of queued requests waiting for a concurrency slot.
If exceeded:
request is rejected
usually with overload status
isolation
Execution mode for the method.
Allowed values:
"none" or omitted
"worker"
"worker" means the method may be executed in a separate isolated process/worker path.
circuitBreaker
Optional upstream failure protection.
Example:
circuitBreaker: { key: "cerit-chat", failureThreshold: 5, resetAfterMs: 30000 } Structured logging
The runtime emits structured logs for RPC execution.
Typical events include:
rpc.complete
rpc.error
rpc.timeout
rpc.rejected
rpc.circuit_open
Typical fields include:
timestamp
request id
module/plugin id
method
auth context
duration
status
error code
process id
Structured logging is meant for:
debugging
production monitoring
tracing overload/failure patterns
Concurrency control
Concurrency is enforced per RPC method key.
Typical behavior:
-
if active calls are below maxConcurrency, run immediately
-
otherwise queue
-
if queue is full, reject
This prevents one method or integration from monopolizing the process.
Circuit breakers
Circuit breakers help when upstream dependencies are failing.
Behavior:
-
failures accumulate for a breaker key
-
once threshold is reached, the breaker opens
-
open breaker rejects requests immediately
-
after resetAfterMs, the breaker allows a trial call again
This prevents the server from flooding a broken upstream.
Worker isolation
Methods marked with:
run in isolated execution.Important limitation Worker-isolated methods receive a reduced serializable context, not live server objects. They should not depend on: raw
req,res, non-serializable mutable objects, direct closures into the live server runtime They should depend on: method inpu, basic user/session metadata, simple config data, serializable context fields
Request size limits
RPC requests are JSON-based and have size limits.
This protects the server from:
-
accidental huge payloads
-
memory pressure
-
abuse
The request body is rejected early when the configured byte limit is exceeded.
Multi-process deployment
Single-process mode
Clustered mode Optional worker count:XOPAT_WORKERS=4 node cluster-index.js
Development - Core RPC
Start server with: --dev / XOPAT_DEV_MODE=1 flag
Start example: node server/node/index.js --dev or: XOPAT_DEV_MODE=1 node server/node/index.js
Browser example: window.xserver.server.core.getStatus() window.xserver.server.core.getLogs({ afterId: 0, limit: 200 })
These built-in server RPC routes are available only in dev mode:
window.xserver.server.core.getStatus(payload?)window.xserver.server.core.getLogs(payload?)
window.xserver.server.dev.getLogs(...) remains available as a compatibility alias.