Skip to Content
Editor SDKServer API

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

ParameterTypeDescription
tokenstringThe raw JWT from the X-Foir-Token header.
jwksUrlstringThe platform’s JWKS endpoint. Always https://api.foir.io/.well-known/jwks.json in production.
optionsVerifyScopedTokenOptionsOptional. 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’s FOIR_SIGNING_SECRET. The CLI publishes this secret to your service when you run foir 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

ParameterTypeDescription
payloadstringThe raw request body as a string.
signaturestringThe hex-encoded HMAC signature from the request header.
secretstringThe 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.

Last updated on