Server API Reference
The server entry point (@foir/sdk/server) provides utilities for app and extension API backends running on Node.js or Cloudflare Workers. It has zero dependencies and uses the Web Crypto API for token verification and HMAC.
import {
createServerClient,
verifyScopedToken,
parseSubject,
parseContextHeader,
recordsReadModels,
recordsWriteModels,
createCallbackClient,
verifyWebhookSignature,
ServerClientError,
} from '@foir/sdk/server';
import type {
ServerClient,
ServerClientOptions,
GraphQLResponse,
EditorPlacement,
ScopedTokenClaims,
ScopedTokenSubject,
VerifyScopedTokenOptions,
OperationPayload,
CallbackClient,
CallbackResult,
} from '@foir/sdk/server';createServerClient
Creates a client for runtime communication with the Foir platform. It exposes a single query() method — a thin transport over the per-model public GraphQL API. Use it to read records and to issue record-write mutations.
import { createServerClient } from '@foir/sdk/server';
const client = createServerClient({
baseUrl: 'https://api.foir.dev',
apiKey: 'sk_project_xxx',
});ServerClientOptions
interface ServerClientOptions {
/** Base URL for the platform API (e.g., 'https://api.foir.dev'). */
baseUrl: string;
/** Project-scoped API key (sk_* format). */
apiKey: string;
/**
* Optional. Retained for backwards compatibility with older extension
* scaffolds that pass it; the current client ignores it.
*/
configKey?: string;
}ServerClient interface
interface ServerClient {
query<T = Record<string, unknown>>(
query: string,
variables?: Record<string, unknown>
): Promise<GraphQLResponse<T>>;
}query
Execute a GraphQL query or mutation against the platform’s public API. The public API is per-model typed: each model exposes its own list query (products(first:) { edges { node { ... } } }), singular query (product(id:)), and mutations. Typed per-model queries are the recommended path; generic record(id:) and records(modelKey:) escape-hatch queries also exist, returning untyped JSON (and requiring the records:read scope).
const { data } = await client.query<{
products: { edges: Array<{ node: { _id: string; title: string } }> };
}>(`
query ListProducts($first: Int) {
products(first: $first) {
edges { node { _id title } }
}
}
`, { first: 100 });To write records with an API key (sk_*), issue the per-model create<Model> / update<Model> mutation through the same query() method:
await client.query(`
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) { _id }
}
`, {
input: {
naturalKey: 't-shirt',
title: 'T-Shirt',
price: 29.99,
},
});The editor’s scoped-token surface additionally exposes an upsertRecord mutation that creates-or-merges a record by (modelKey, naturalKey):
upsertRecord(modelKey: String!, naturalKey: String!, data: JSON!): UpsertRecordResultIt returns UpsertRecordResult { record, created } — record is the upserted row as JSON and created is true when the row was inserted (rather than merged). It is reachable only with a scoped token; API-key (sk_*) callers must use the per-model create<Model> / update<Model> mutations above.
EditorPlacement
EditorPlacement describes where an editor iframe appears in the admin record editor. Apps return placements from their config so the platform knows how to mount the iframe.
interface EditorPlacement {
/** 'main-editor' replaces / augments the content tab; 'sidebar' renders in the sidebar. */
type: 'main-editor' | 'sidebar';
/** Full URL of the editor iframe. */
url: string;
/** Allowed postMessage origin (defaults to url origin). */
allowedOrigin?: string;
/** Iframe height: 'auto' (resize via postMessage), 'fill' (100%), or fixed px. */
height?: 'auto' | 'fill' | number;
/** Tab label for main-editor placements (defaults to config name). */
tabName?: string;
/** When true, hides the standard Content tab (main-editor only). */
hideContentTab?: boolean;
/** Which model keys this placement applies to; omit for all models. */
modelKeys?: string[];
}Operation Verification (Scoped Tokens)
Operation dispatches from the platform carry a short-lived scoped token in the X-Foir-Token header. Verify it against the platform’s JWKS endpoint before processing the request. The legacy HMAC X-Foir-Signature path is no longer used for operation dispatches; its helpers were removed in v0.8.0 (see Migrating from HMAC below).
verifyScopedToken
Verifies the JWT signature against the platform JWKS, checks the issuer, and validates exp / nbf. Returns the parsed claims on success; throws on any failure.
import { verifyScopedToken } from '@foir/sdk/server';
const claims = await verifyScopedToken(
token,
'https://api.foir.dev/.well-known/jwks.json',
);Parameters
| Parameter | Type | Description |
|---|---|---|
token | string | The raw JWT from the X-Foir-Token header. |
jwksUrl | string | The platform’s scoped-token JWKS endpoint — https://api.foir.dev/.well-known/jwks.json. |
options | VerifyScopedTokenOptions | Optional. { expectedIssuer?: string } — expectedIssuer defaults to 'foir-platform'. |
ScopedTokenClaims
interface ScopedTokenClaims {
iss: string; // 'foir-platform'
sub: string; // pipe-delimited '<tenantId>|<projectId>|<appName>'
cap: string[]; // capabilities, e.g. ['records:read', 'credentials:read']
jti: string; // unique token id
exp: number; // expiry (unix seconds)
nbf: number; // not-before (unix seconds)
iat: number; // issued-at (unix seconds)
ctx?: Record<string, string>; // operation/execution context
rrm?: string[]; // records-read model allow-list
rwm?: string[]; // records-write model allow-list
}JWKS keys are cached per-isolate via globalThis for 5 minutes to match the endpoint’s Cache-Control max-age.
VerifyScopedTokenOptions
interface VerifyScopedTokenOptions {
/**
* Expected `iss` claim. Defaults to `'foir-platform'`. Override only
* when verifying tokens from a non-default issuer (test harnesses,
* alternate deploys).
*/
expectedIssuer?: string;
}parseSubject
Splits the pipe-delimited sub claim into a typed ScopedTokenSubject. Throws on a missing or empty segment.
const { tenantId, projectId, appName } = parseSubject(claims.sub);interface ScopedTokenSubject {
tenantId: string;
projectId: string;
appName: string;
}parseContextHeader
Parses the semicolon-delimited X-Foir-Context header the platform attaches to dispatched operation requests. Shape: project=P;app=A;operation=O;triggered_by=T;execution_id=E;causation_chain=a,b,c.
const ctx = parseContextHeader(req.headers.get('x-foir-context'));
console.log(ctx.execution_id, ctx.triggered_by);recordsReadModels / recordsWriteModels
Convenience helpers to read the per-model capability allow-lists. Equivalent to claims.rrm ?? [] and claims.rwm ?? [].
const readable = recordsReadModels(claims);
if (!readable.includes('product')) {
return new Response('forbidden', { status: 403 });
}Full middleware example (Hono)
import { Hono } from 'hono';
import {
verifyScopedToken,
parseSubject,
parseContextHeader,
} from '@foir/sdk/server';
import type { ScopedTokenClaims } from '@foir/sdk/server';
const JWKS_URL = 'https://api.foir.dev/.well-known/jwks.json';
const app = new Hono<{
Variables: { claims: ScopedTokenClaims; ctx: Record<string, string> };
}>();
async function verifyDispatch(c, next) {
const token = c.req.header('x-foir-token');
if (!token) return c.json({ error: 'missing token' }, 401);
try {
const claims = await verifyScopedToken(token, JWKS_URL);
c.set('claims', claims);
c.set('ctx', parseContextHeader(c.req.header('x-foir-context')));
await next();
} catch (err) {
return c.json({ error: 'invalid token', detail: String(err) }, 401);
}
}
app.post('/operations/*', verifyDispatch);
app.post('/operations/process', async (c) => {
const claims = c.get('claims');
const ctx = c.get('ctx');
const { appName, projectId } = parseSubject(claims.sub);
const payload = await c.req.json();
// Use claims.cap / recordsReadModels(claims) to authorize what this
// dispatch is allowed to do; use ctx.execution_id for callback wiring.
return c.json({ success: true, result: { processed: true } });
});Async-Callback Client
When the platform dispatches an operation in async mode, the request body includes a callback block. Your endpoint returns 202 Accepted, queues the work, then later calls back to the public API to report progress or terminal state. Use createCallbackClient to wrap that callback API.
createCallbackClient
import { createCallbackClient } from '@foir/sdk/server';
import type { OperationPayload } from '@foir/sdk/server';
const payload: OperationPayload = await req.json();
if (payload.callback && payload.executionId) {
const cb = createCallbackClient(payload.callback, {
executionId: payload.executionId,
signingSecret: process.env.FOIR_SIGNING_SECRET!,
});
// Report progress as work proceeds
await cb.progress({ pct: 25, message: 'Fetched source data' });
// ... do work ...
// Report success
await cb.complete({ recordCount: 142, durationMs: 3500 });
}CallbackClient interface
interface CallbackClient<TResult = unknown> {
complete(result: TResult): Promise<CallbackResult>;
fail(
code: string,
message: string,
opts?: { retryable?: boolean; details?: Record<string, unknown> }
): Promise<CallbackResult>;
progress(update: {
pct?: number;
message?: string;
metadata?: Record<string, unknown>;
}): Promise<CallbackResult>;
cancel(): Promise<CallbackResult>;
}
interface CallbackResult {
/** Status as seen by the platform AFTER the call. */
status: string;
/** True when the admin cancelled the execution while the extension was working. */
cancelled: boolean;
/** Progress only: false when the monotonic guard rejected an out-of-order update. */
applied?: boolean;
}Auth on callback requests
Each callback request carries two pieces of auth:
Authorization: Bearer <callback.token>— the scoped token bundled in the dispatch envelope, with capabilities specifically for this execution’s callback mutations.X-Foir-Signature: sha256=<hex>— HMAC-SHA256 of the raw request body, keyed on your project’sFOIR_SIGNING_SECRET. The CLI publishes this secret to your service when you runfoir push.
The SDK handles both — you only need to ensure FOIR_SIGNING_SECRET is set in your service’s environment.
Cancellation handling
progress returns { cancelled: true } when the admin has cancelled the execution while your service was still working. Stop work immediately and (optionally) call fail('CANCELLED', '...') to record the terminal state.
for (let i = 0; i < items.length; i++) {
const result = await cb.progress({
pct: Math.floor((i / items.length) * 100),
message: `Processed ${i} of ${items.length}`,
});
if (result.cancelled) {
console.log('admin cancelled, stopping work');
return;
}
await processItem(items[i]);
}
await cb.complete({ processedCount: items.length });Per-op typed results
createCallbackClient<TResult> accepts a generic for the operation’s typed result shape. Generate this type with graphql-codegen against the public API schema — the platform emits a per-op typed mutation complete{Op}Execution whose result argument matches the operation’s declared output_schema.
import type { CompleteTranslateExecutionMutationVariables } from './generated';
type TranslateResult = CompleteTranslateExecutionMutationVariables['result'];
const cb = createCallbackClient<TranslateResult>(payload.callback!, {
executionId: payload.executionId!,
signingSecret: process.env.FOIR_SIGNING_SECRET!,
});Webhook Verification
For inbound webhooks where an external service (Shopify, Stripe, GitHub) signs the request body, verify with verifyWebhookSignature. This is HMAC-SHA256 with a shared secret — the standard webhook pattern.
This is unrelated to operation dispatches. Operation dispatches use scoped tokens (above); only inbound webhooks from third-party services use HMAC.
import { verifyWebhookSignature } from '@foir/sdk/server';
app.post('/webhooks/shopify', async (c) => {
const body = await c.req.text();
const signature = c.req.header('x-shopify-hmac-sha256') ?? '';
const valid = await verifyWebhookSignature(
body,
signature,
process.env.SHOPIFY_WEBHOOK_SECRET!,
);
if (!valid) {
return c.text('Invalid signature', 401);
}
const payload = JSON.parse(body);
// Process webhook...
});Parameters
| Parameter | Type | Description |
|---|---|---|
payload | string | The raw request body as a string. |
signature | string | The hex-encoded HMAC signature from the request header. |
secret | string | The shared secret with the source service. |
Returns Promise<boolean> — true if the signature is valid, false otherwise.
OperationPayload
The standardized payload structure sent by the platform to your operation endpoint.
interface OperationPayload {
/** Execution id — present so the extension can include it in callbacks. */
executionId?: string;
/** Unique key identifying this operation. */
operationKey: string;
/** How this operation was triggered. */
trigger: OperationTrigger;
/** Input data provided by the caller. */
input: Record<string, unknown>;
/** Content/field value (for field-triggered operations). */
content: unknown;
/** Record context, if the operation was triggered from a record. */
record: OperationRecordContext | null;
/** Platform context (tenant, project, user, locale). */
context: OperationContext;
/** App credentials, auto-injected by the platform. */
credentials?: Record<string, unknown>;
/** App config, auto-injected by the platform. */
config?: Record<string, unknown>;
/** Async-callback dispatch metadata. Present iff dispatched in 'async' mode. */
callback?: OperationCallback;
}
interface OperationTrigger {
type: 'ui' | 'api' | 'lifecycle' | 'field';
fieldKey?: string;
fieldType?: string;
}
interface OperationRecordContext {
id: string;
modelKey: string;
versionId?: string;
data: Record<string, unknown>;
metadata: { naturalKey?: string; [key: string]: unknown };
}
interface OperationContext {
tenantId: string;
projectId: string;
userId?: string;
customerId?: string;
locale?: string;
timestamp: string;
}
interface OperationCallback {
gqlEndpoint: string;
token: string; // scoped Bearer token
expiresAt: string; // RFC3339
mutations: {
complete: string; // e.g. 'completeTranslateExecution'
fail: string;
progress: string;
cancel: string;
};
}OperationResult
The standard response shape your operation endpoint should return for sync dispatches. Async dispatches return 202 Accepted and report results via the callback client instead.
interface OperationResult {
success: boolean;
result?: unknown;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
}Success example:
return c.json({
success: true,
result: { redirectCount: 42 },
metadata: { durationMs: 1200 },
});Error example:
return c.json({
success: false,
error: {
code: 'MISSING_CREDENTIALS',
message: 'Cloudflare API token is not configured',
},
});Error Classes
ServerClientError
Thrown by createServerClient methods when a platform API request fails.
class ServerClientError extends Error {
status?: number;
response?: unknown;
}Migrating from HMAC
Earlier SDK versions shipped verifyOperationSignature / verifyAndParseOperation (and the OperationVerificationError class) for the legacy HMAC operation-dispatch path. These were removed in @foir/sdk@0.8.0 — scoped tokens are now the only inbound auth path.
The platform stopped emitting X-Foir-Signature for operation dispatches when the scoped-token model shipped — every operation request now carries X-Foir-Token instead. If you are upgrading from v0.7.x:
- import { verifyAndParseOperation } from '@foir/sdk/server';
+ import { verifyScopedToken, parseSubject } from '@foir/sdk/server';
app.post('/operations/process', async (c) => {
- const body = await c.req.text();
- const signature = c.req.header('x-foir-signature');
- const payload = await verifyAndParseOperation(body, signature, {
- webhookSecret: process.env.WEBHOOK_SECRET!,
- });
+ const token = c.req.header('x-foir-token');
+ if (!token) return c.json({ error: 'missing token' }, 401);
+ const claims = await verifyScopedToken(
+ token,
+ 'https://api.foir.dev/.well-known/jwks.json',
+ );
+ const payload = await c.req.json();
+ const { appName } = parseSubject(claims.sub);
// ...
});WEBHOOK_SECRET (or FOIR_SIGNING_SECRET) is no longer needed for the inbound path. It’s still required for outbound callbacks in async mode (createCallbackClient HMACs the request body with it) — see Async-Callback Client above.