Server API Reference
The server entry point (@eide/foir-editor-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,
// Deprecated — for migrating off the legacy HMAC operation-dispatch path:
verifyOperationSignature,
verifyAndParseOperation,
ServerClientError,
OperationVerificationError,
} from '@eide/foir-editor-sdk/server';createServerClient
Creates a client for runtime communication with the Foir platform. Use it to ingest data, query records via GraphQL, and read your app’s config.
import { createServerClient } from '@eide/foir-editor-sdk/server';
const client = createServerClient({
baseUrl: 'https://api.foir.io',
apiKey: 'sk_project_xxx',
configKey: 'my-app',
});ServerClientOptions
interface ServerClientOptions {
/** Base URL for the platform API (e.g., 'https://api.foir.io'). */
baseUrl: string;
/** Project-scoped API key (sk_* format). */
apiKey: string;
/** Config key identifying your app (e.g., 'shopify', 'redirector'). */
configKey: string;
}ServerClient interface
interface ServerClient {
ingest(items: IngestItem[]): Promise<{ processed: number; errors: number }>;
query<T = Record<string, unknown>>(
query: string,
variables?: Record<string, unknown>
): Promise<GraphQLResponse<T>>;
getConfig(): Promise<ConfigRecord>;
}ingest
Push data items to the platform.
await client.ingest([
{
resource: 'record',
event: 'upsert',
modelKey: 'product',
externalId: 'prod-123',
naturalKey: 't-shirt',
data: { title: 'T-Shirt', price: 29.99 },
},
{
resource: 'record',
event: 'delete',
modelKey: 'product',
externalId: 'prod-456',
},
]);query
Execute a GraphQL query or mutation against the platform’s public API.
const { data } = await client.query<{
records: { items: Array<{ id: string; data: Record<string, unknown> }> };
}>(`
query ListRecords($modelKey: String!, $limit: Int) {
records(modelKey: $modelKey, limit: $limit) {
items { id data }
}
}
`, { modelKey: 'redirect', limit: 100 });getConfig
Retrieve your app’s config record from the platform. Returns:
interface ConfigRecord {
key: string;
name: string;
config: Record<string, unknown>;
credentials?: Record<string, unknown>;
}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 and the helpers for it are deprecated (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 '@eide/foir-editor-sdk/server';
const claims = await verifyScopedToken(
token,
'https://api.foir.io/.well-known/jwks.json',
);Parameters
| Parameter | Type | Description |
|---|---|---|
token | string | The raw JWT from the X-Foir-Token header. |
jwksUrl | string | The platform’s JWKS endpoint. Always https://api.foir.io/.well-known/jwks.json in production. |
options | VerifyScopedTokenOptions | Optional. Currently { expectedIssuer?: string } — 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.
parseSubject
Splits the pipe-delimited sub claim into a typed object. Throws on a missing or empty segment.
const { tenantId, projectId, appName } = parseSubject(claims.sub);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 '@eide/foir-editor-sdk/server';
import type { ScopedTokenClaims } from '@eide/foir-editor-sdk/server';
const JWKS_URL = 'https://api.foir.io/.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 '@eide/foir-editor-sdk/server';
import type { OperationPayload } from '@eide/foir-editor-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 '@eide/foir-editor-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',
},
});IngestItem
Describes a single data item to push to the platform via client.ingest().
interface IngestItem {
resource: 'record' | 'customer' | 'locale';
event: 'upsert' | 'delete';
modelKey: string;
externalId: string;
naturalKey?: string;
data?: Record<string, unknown>;
}Error Classes
ServerClientError
Thrown by createServerClient methods when a platform API request fails.
class ServerClientError extends Error {
status?: number;
response?: unknown;
}OperationVerificationError
Thrown by verifyAndParseOperation (deprecated). Kept for parity with the old SDK surface during migration.
class OperationVerificationError extends Error {
code: 'MISSING_SECRET' | 'MISSING_SIGNATURE' | 'INVALID_SIGNATURE' | 'INVALID_JSON';
receivedSignature?: string;
expectedSignature?: string;
cause?: unknown;
}Migrating from HMAC
verifyOperationSignature and verifyAndParseOperation were the inbound auth helpers for the legacy HMAC dispatch path. They are deprecated as of @eide/foir-editor-sdk@0.7.0 and will be removed in v0.8.0.
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.
To migrate:
- import { verifyAndParseOperation } from '@eide/foir-editor-sdk/server';
+ import { verifyScopedToken, parseSubject } from '@eide/foir-editor-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.io/.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.