@fepro/workhub-app-backend
v0.6.0
Published
Server-side SDK for WorkHub developer workspaces. defineWorkspace() to declare routes + scopes; run() boots a JWT-verifying HTTP server that the WorkHub proxy calls into.
Maintainers
Readme
@fepro/workhub-app-backend
Server-side SDK for WorkHub developer workspaces. Declare your routes and scopes; the SDK boots an HTTP server, verifies the WorkHub identity token on every request, and hands your handler a fully-authenticated context with a tenant-scoped database client.
Pairs with @fepro/workhub-app-sdk
on the frontend, which calls into your routes via workhub.backend.fetch().
Install
npm install @fepro/workhub-app-backend
# or
pnpm add @fepro/workhub-app-backendQuick start
// backend/workspace.config.ts
import { defineWorkspace, run } from '@fepro/workhub-app-backend';
const workspace = defineWorkspace({
key: 'crm-x',
scopes: [
'crm-x:leads:read',
'crm-x:leads:write',
],
routes: {
'GET /leads': {
scope: 'crm-x:leads:read',
handler: async (ctx) => {
return ctx.db`
select id, name, email
from leads
where tenant_id = ${ctx.tenantId}
`;
},
},
'POST /leads': {
scope: 'crm-x:leads:write',
handler: async (ctx) => {
const body = ctx.request.body as { name: string; email: string };
const [row] = await ctx.db`
insert into leads (tenant_id, name, email)
values (${ctx.tenantId}, ${body.name}, ${body.email})
returning id, name, email
`;
return row;
},
},
'GET /leads/:id': {
scope: 'crm-x:leads:read',
handler: async (ctx) => {
const [row] = await ctx.db`
select * from leads
where tenant_id = ${ctx.tenantId} and id = ${ctx.request.params.id}
`;
if (!row) {
throw new Error('not_found');
}
return row;
},
},
},
});
run(workspace);That's the whole backend. No Express, no manual JWT handling, no auth
middleware — defineWorkspace declares the contract, run boots the
HTTP server and hooks JWT verification + scope enforcement into every
request.
What's in v0.2
This release pulls every cross-cutting concern that workspace teams were re-implementing on their own back into the SDK. Each item is opt-in (except the always-on request-id / JSON logging) — if you don't use it, nothing changes versus v0.1.
| feature | what it gives you | opt-in via |
| --- | --- | --- |
| AppError envelope | structured { code, message, requestId } 4xx/5xx | throw errors.notFound('...') |
| Request-id correlation | ctx.requestId + x-request-id echoed on every response | always-on, honors X-Request-Id header |
| Structured JSON logs | one JSON line per ctx.log call with stable fields | always-on (replaces text logger) |
| Idempotency-Key | dedupe POST/PUT/PATCH retries with stored response | idempotent: true on the route |
| Rate limit + body cap | 600 req/min/tenant token bucket; 1 MB body limit | limits.rateLimiter / limits.maxBodyBytes |
| Nestable db.begin | inner db.begin(...) maps to savepoints automatically | use withTenantContext |
| Statement timeout | pool-wide PG statement_timeout set at startup | DATABASE_STATEMENT_TIMEOUT_MS env or database.statementTimeoutMs |
| Migration runner | NNN_*.sql files applied on boot with hash tracking | database.migrationsPath |
| Job queue | Postgres-backed jobs with retries + FOR UPDATE SKIP LOCKED | import { JobQueue } |
| Scheduler | interval + 5-field cron, in-process | import { Scheduler } |
| Event emit | ctx.events.emit('customer.created', {...}) → outbox → bridge | always-on |
| Secrets accessor | ctx.secrets.get('STRIPE_API_KEY') with audit + missing-key enforcement | declare in secrets: [...] |
| Health probes | run user-defined checks on /healthz, returns 503 if any fails | health.checks: [...] |
| Both-case JWT claims | accepts tenant_id AND tenantId (and scope-string) | always-on |
Error envelope
import { defineWorkspace, errors } from '@fepro/workhub-app-backend';
defineWorkspace({
routes: {
'GET /leads/:id': {
scope: 'crm-x:leads:read',
handler: async (ctx) => {
const [row] = await ctx.db`select * from leads where id = ${ctx.request.params.id}`;
if (!row) throw errors.notFound('lead not found', { id: ctx.request.params.id });
return row;
},
},
},
// ...
});The client gets:
{ "__workhub_error": true, "code": "not_found", "message": "lead not found",
"details": { "id": "abc" }, "requestId": "req_b1f3..." }The frontend SDK's workhub.backend.call() rethrows this as a typed
WorkhubError so apps can branch on err.code.
Idempotency
'POST /payments': {
scope: 'crm-x:payments:write',
idempotent: true, // ← header is honored on this route
handler: async (ctx) => { /* ... */ },
},The SDK ships a memory store (single-instance) and a Postgres store
(multi-instance). Switch via idempotency.store:
import { createPostgresIdempotencyStore, getDb } from '@fepro/workhub-app-backend';
const db = getDb();
defineWorkspace({
idempotency: { store: createPostgresIdempotencyStore(db) },
// ...
});Rate limit + body size
Defaults are 600 req/min per (tenant, app) and 1 MB body. Override:
limits: {
maxBodyBytes: 256 * 1024,
rateLimiter: createTokenBucketLimiter({ capacity: 120, refillPerSecond: 20 }),
}
// or to disable rate limiting:
limits: { rateLimiter: false }Migrations
database: { migrationsPath: 'migrations' }On boot the SDK creates workhub_schema_migrations, hashes every file in
the directory, and applies any new ones in lexicographic order. Editing an
already-applied file throws — silent drift is one of the production red
lines.
Jobs + scheduler
import { JobQueue, Scheduler, getDb } from '@fepro/workhub-app-backend';
const db = getDb();
const jobs = new JobQueue(db);
const sched = new Scheduler();
jobs.define<{ leadId: string }>({
name: 'send-welcome-email',
handler: async ({ job, log }) => { /* ... */ },
});
sched.define({
name: 'nightly-cleanup',
cron: '17 3 * * *', // 03:17 UTC daily
handler: () => jobs.enqueue('cleanup', { what: 'old-temp-files' }).then(() => undefined),
});
jobs.start();
sched.start();Jobs use FOR UPDATE SKIP LOCKED so multiple workspace instances coordinate
without ever double-running. Failed handlers retry with exponential backoff
up to maxAttempts (default 5), then transition to failed — no silent
drop.
Events
'POST /leads': {
scope: 'crm-x:leads:write',
handler: async (ctx) => {
const [row] = await ctx.db`insert into leads ... returning *`;
await ctx.events.emit('customer.created', { id: row.id, displayName: row.name });
return row;
},
},emit writes to workhub_events_outbox inside the request's DB connection.
The platform's bridge polls the table, forwards each row over NATS as
tenant.<id>.app.<app-key>.<kind>, and the frontend SDK delivers it to
iframes that called workhub.events.on('app:customer.created', cb).
Secrets
Declare in the workspace config:
defineWorkspace({
secrets: [
{ key: 'STRIPE_API_KEY', description: 'Stripe secret key', auditAccess: true },
{ key: 'WEBHOOK_SLACK_URL', required: false },
{ key: 'PII_ENCRYPTION_KEY', scope: 'tenant', auditAccess: true },
],
// ...
});The platform deploy pipeline injects env vars matching the declared keys
(STRIPE_API_KEY=...). tenant-scoped secrets are looked up first as
PII_ENCRYPTION_KEY__<tenantId> before falling back to the unscoped key.
const stripe = new Stripe(ctx.secrets.get('STRIPE_API_KEY'));
const slack = ctx.secrets.tryGet('WEBHOOK_SLACK_URL'); // null if absentget throws if a required secret isn't present — fail loud rather than
ship a undefined API key downstream.
Integrations (0.5)
Call the tenant's installed cluster integrations (e.g. BMS ML document AI) without ever touching their credentials. Declare what you use:
defineWorkspace({
integrations: [{ provider: 'bms_ml', required: false }],
// ...
});Then call it from a handler:
// List the tenant's extracted invoices/documents.
const page = await ctx.integrations.call('bms_ml', 'documentList', { page: 1 });
// → { count, next, previous, results: [{ id, filename, status, ... }] }
// One document's detail:
const doc = await ctx.integrations.call('bms_ml', 'documentGet', { id: 42 });The SDK forwards the current request's identity token to the platform, which verifies it, finds the tenant's active cluster for the provider, decrypts the BYOC credential server-side, dispatches through the cluster adapter, and returns the result — your app never sees the credential.
ctx.integrations.available() is false when the platform didn't inject
WORKHUB_API_URL (standalone/dev); call() then throws
integrations_unavailable. Platform-side failures throw IntegrationCallError
carrying .code (e.g. integration_not_installed, unknown_operation),
.status, and .detail.
bms_ml operations (read slice): documentList, documentGet, trainingList,
modelVersionList, ocrStatus, health.
Health probes
health: {
checks: [
{ name: 'db', check: async () => ({ ok: !!(await ctx.db`select 1`) }) },
{ name: 'worker', check: () => ({ ok: workerHeartbeatRecent() }) },
],
}GET /healthz runs every probe in parallel (default 2s timeout each) and
returns 200 / status: 'ok' if all pass, else 503 / status: 'degraded'
with per-probe detail.
Logging
ctx.log now emits JSON lines:
{"ts":"2026-05-19T18:11:24.183Z","level":"info","msg":"lead inserted",
"request_id":"req_b1f3...","tenant_id":"t_acme","app_key":"leads",
"install_id":"i_42","workspace":"crm-x","lead_id":"l_900"}Pass extra fields as the second argument:
ctx.log.info('lead inserted', { lead_id: row.id });Use log.child({...}) to make a long-running operation's logs all share
extra fields (handy inside jobs).
Streaming responses (SSE / NDJSON) — 0.4
Return streamingResponse({headers, start}) from a handler to take over
the HTTP response. Designed for SSE (text/event-stream), NDJSON, or any
other streaming protocol. The SDK doesn't constrain the framing; you
write bytes via the sink.
import { defineWorkspace, streamingResponse } from '@fepro/workhub-app-backend';
defineWorkspace({
routes: {
'GET /events/stream': {
scope: 'crm-x:events:read',
handler: (ctx) => streamingResponse({
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
'x-accel-buffering': 'no',
},
start: async (sink) => {
// Resume from where the client disconnected — EventSource sends
// Last-Event-ID on every reconnect.
const since = parseSince(ctx.request.headers['last-event-id']);
for await (const ev of tailTenantEvents(ctx, since)) {
sink.write(`id: ${ev.id}\nevent: ${ev.kind}\ndata: ${JSON.stringify(ev)}\n\n`);
if (sink.aborted) return; // client disconnected
}
},
}),
},
},
});The sink exposes:
write(chunk)— string or Uint8Array; returnsfalseon backpressure.flush()— flush whatever's currently buffered (useful before a long idle window so intermediaries don't time out).end()— terminate. Called automatically whenstartresolves.onClose(cb)— register a callback for client disconnect (use it to break out offor awaitloops that read external sources).aborted— boolean, true once the client connection is closed.
All pre-handler middleware (auth, scope check, tenant context, rate
limit, body cap, request id, stmt timeout) runs identically. Idempotency
is intentionally skipped on streaming routes — declare idempotent:
false (or omit it).
Headers are flushed immediately after writeHead so the first event
reaches the client before any intermediary times the idle connection
out. The platform's apps-runtime proxy already passes streams through
unbuffered.
Pair with new EventSource(url, { withCredentials: true }) on the
frontend (the per-installation bundle cookie must travel with the
stream request, otherwise the proxy 401s).
What the platform injects
When WorkHub deploys your container, it sets these environment variables. The SDK reads them automatically; you don't reference them directly:
| variable | what it is |
| --- | --- |
| PORT | port to listen on (default 3000) |
| WORKHUB_JWKS_URL | JWKS endpoint the SDK uses to verify identity tokens |
| WORKHUB_AUDIENCE | expected aud claim, e.g. workspace:crm-x |
| WORKHUB_ISSUER | expected iss claim, e.g. https://api.workhub.io |
| DATABASE_URL | populated when your manifest declares database.mode: 'platform-shared' |
For local development, set these yourself or use a dev WorkHub instance that mints test tokens against your container.
Local development
The deploy pipeline injects the env vars above in production. Locally you set
them yourself. The one thing that trips people up is JWKS reachability:
the SDK verifies every identity token against WORKHUB_JWKS_URL, and the dev
WorkHub API serves that at http://localhost:4000/.well-known/jwks.json. How
your backend reaches that URL depends on where it runs:
Recommended — run your backend directly on your machine (not containerized).
Your backend shares localhost with the dev API, so the default URL just
works. Set the env vars and start it:
export PORT=3000
export DATABASE_URL=postgres://… # your local DB
export WORKHUB_JWKS_URL=http://localhost:4000/.well-known/jwks.json
export WORKHUB_ISSUER=https://api.workhub.io # must match the dev API's issuer
export WORKHUB_AUDIENCE=workspace:<your-key>
node dist/index.jsThis is the fastest inner loop — no image rebuild between edits.
If you run your backend in a container (Docker), localhost inside the
container is the container, not your host. Point JWKS at the host gateway:
# macOS / Windows Docker Desktop:
WORKHUB_JWKS_URL=http://host.docker.internal:4000/.well-known/jwks.json
# Linux (no host.docker.internal): use the docker bridge gateway IP, e.g.
# docker network inspect bridge --format='{{range .IPAM.Config}}{{.Gateway}}{{end}}'
WORKHUB_JWKS_URL=http://172.17.0.1:4000/.well-known/jwks.jsonDon't have the platform running at all? Point the env vars at a shared
staging instance (https://stg-api.<domain>/.well-known/jwks.json + that
instance's issuer/audience) and exercise your backend with tokens it mints.
The issuer/audience must match what the token was minted with, or verification fails with
401 invalid X-WorkHub-Identity token— that error almost always means a JWKS/issuer/audience mismatch, not a code bug.
The Ctx object
Every route handler receives a single Ctx argument, fully populated from
the verified JWT and the request:
interface Ctx {
// Identity, signed by WorkHub
tenantId: string;
userId: string;
appKey: string; // which app surface initiated the call
scopes: string[]; // scopes granted to that app
workspaceInstallId: string;
workspaceKey: string; // your workspace key
requestId: string; // X-Request-Id or minted by SDK
// Database — postgres tagged-template client (statement_timeout set on pool)
db: Sql;
// Structured JSON logger; auto-includes request_id + tenant_id + app_key
log: { info, warn, error };
// Tenant-event emit — writes to outbox, platform bridge forwards to iframes
events: { emit(kind: string, payload: object): Promise<string> };
// Workspace secrets — declared in `secrets: [...]`, injected as env vars
secrets: { get(key: string): string; tryGet(key: string): string | null };
// Request data
request: {
method: string;
path: string;
query: URLSearchParams;
headers: Record<string, string>;
body: unknown;
rawBody: string; // exact bytes the client sent (pre-parse)
params: Record<string, string>; // from `:name` in the route key
requestId: string; // same id as ctx.requestId
};
}Use ctx.tenantId to filter every query — it comes from the JWT, not
from the request body, so it can't be spoofed. On the platform-shared
deployment tier, this is what isolates your tenants.
Verifying signed webhooks — ctx.request.rawBody
ctx.request.body is the parsed value; ctx.request.rawBody is the
exact bytes the client sent (empty string for GET/HEAD or a body-less
request). Use rawBody whenever a byte-for-byte match matters — HMAC-over-body
webhook signatures (Stripe, GitHub, Shopify) are computed over the bytes sent,
and re-serialising body does not reproduce them (whitespace, escapes, and key
order all drift). The Stripe secret comes from a declared workspace secret:
'POST /billing/webhook': {
scope: 'bms-erp:billing:write',
handler: async (ctx) => {
const event = stripe.webhooks.constructEvent(
ctx.request.rawBody, // not JSON.stringify(ctx.request.body)
ctx.request.headers['stripe-signature'], // headers are lower-cased
ctx.secrets.get('STRIPE_WEBHOOK_SECRET'),
);
// event.type is now trustworthy …
return { received: true };
},
}Outbound network (egress)
In production your container runs on an internet-isolated network — the only route out is the platform egress gateway, which permits only the destinations you declare (plus the platform's own: your database and the WorkHub API). Everything else is blocked at the network layer, so this is a real boundary, not a guardrail.
Declare what you need in workspace.json:
{
"egress": {
"policy": "allowlist", // or "deny-all" (the default)
"allow": [
{ "host": "api.stripe.com", "ports": [443] },
{ "host": "*.twilio.com", "ports": [443] } // single leading wildcard ok
]
}
}Hosts are domains (filtered by TLS SNI — no traffic decryption); IP literals
aren't accepted (the platform adds your DB/API automatically). deny-all
(the default when you omit the block) still reaches your database and the
platform API — just nothing else.
Make outbound calls with ctx.fetch, not global fetch — it routes
through the gateway (global fetch ignores proxy env and would fail):
const res = await ctx.fetch('https://api.stripe.com/v1/charges', { method: 'POST', /* … */ });A call to a non-allowlisted host throws EgressDeniedError (with the blocked
host) instead of hanging. Locally / standalone (no gateway configured),
ctx.fetch is a transparent pass-through to global fetch.
Row-level security & the non-superuser role
On the platform-shared tier your database is one database shared by all
tenants, isolated by your own RLS policies + ctx.tenantId filtering. RLS
is only a real defense if the connection isn't a Postgres superuser —
superusers (and BYPASSRLS roles) silently skip every policy, so a forgotten
where tenant_id = … would leak across tenants instead of being blocked.
Two guarantees enforce this:
- The platform provisions your role as
NOSUPERUSER NOBYPASSRLS(alsoNOCREATEDB NOCREATEROLE). The role owns your database, so it can run any DDL inside it — create tables, indexes, your own RLS policies — but it is not a cluster superuser. - The SDK refuses to boot (in production) if
ctx.dbis connected as a superuser or aBYPASSRLSrole. SetWORKHUB_ALLOW_SUPERUSER_DB=trueto opt out (single-tenant apps that don't use RLS), orWORKHUB_REQUIRE_NON_SUPERUSER=trueto enforce it in non-production too.
Extensions that need superuser
Because your role isn't a superuser, CREATE EXTENSION only works for
trusted extensions
(pg_trgm, btree_gin, citext, uuid-ossp, etc. on PG13+). For an untrusted
extension, or a function that must be created SECURITY DEFINER as superuser,
ask the platform to install it — the platform's admin role creates it and
grants EXECUTE/USAGE to your workspace role. Your migration can then
assume the extension exists (CREATE EXTENSION IF NOT EXISTS … is a no-op
once the platform pre-installed it).
Routes
Route keys are "<METHOD> <PATH>":
routes: {
'GET /leads': { scope: '...', handler: ... },
'GET /leads/:id': { scope: '...', handler: ... },
'POST /leads': { scope: '...', handler: ... },
'PATCH /leads/:id': { scope: '...', handler: ... },
'DELETE /leads/:id': { scope: '...', handler: ... },
}:name segments become ctx.request.params.name. The SDK matches
exactly (no wildcards, no regex). For more complex routing, split into
multiple workspaces.
Scopes
defineWorkspace enforces three rules at module-load time:
- Every scope listed in the workspace's top-level
scopesarray must start with<workspace.key>:(e.g.crm-x:*). - Every route's
scopemust appear in the workspace'sscopesarray. - Route keys must start with a valid HTTP verb.
Misconfiguration throws synchronously on import — you can't deploy a broken workspace.
At request time, the SDK rejects any call whose JWT doesn't carry the route's required scope (HTTP 403). The proxy controls which app surfaces get which scopes via the workspace + per-app manifest, so your backend only needs to declare what each route requires.
Database
If your workspace.json declares database: { mode: 'platform-shared' },
WorkHub provisions a dedicated database for the workspace and runs the
SQL migrations from your bundle's migrations/ directory before starting
your container. DATABASE_URL is set; ctx.db is a postgres client
backed by it:
// Tagged template — values are parameterised, never interpolated.
const rows = await ctx.db`
select * from leads
where tenant_id = ${ctx.tenantId}
order by created_at desc
limit ${50}
`;The client is shared across requests with a small connection pool (default
max: 10, override with DATABASE_POOL_MAX).
For external databases, set database.mode: 'external' in your manifest
and configure DATABASE_URL via the workspace's deploy_env.
Health
The SDK serves GET /healthz (or whatever manifest.backend.healthcheck
specifies) returning 200 {"status":"ok","workspace":"<key>"} as soon as
the server is listening. WorkHub's deploy pipeline polls this until 2xx
before flipping the deployment to active.
Bundle layout
Your code lives in the workspace bundle. You don't write a Dockerfile —
the platform compiles one from workspace.json#backend.runtime and runs
the build for you. Letting tenants ship arbitrary Dockerfiles was a real
production-time risk (root-as-build-user, secret exfil, arbitrary base
images), so the platform owns it.
my-workspace.zip
├── workspace.json ← declares runtime, port, scopes, apps
├── backend/ ← your source — platform builds this
│ ├── package.json
│ ├── package-lock.json ← (or pnpm-lock.yaml / yarn.lock)
│ ├── workspace.config.ts ← exports defineWorkspace(...)
│ └── … your code
├── apps/
│ └── leads/
│ ├── workhub.json
│ └── dist/index.html
└── migrations/ ← optional, postgres only
├── 001_initial.sql
└── 002_add_leads.sqlworkspace.json declares how to build:
{
"key": "crm-x",
"version": "1.0.0",
"name": "Acme CRM",
"scopes": ["crm-x:leads:read", "crm-x:leads:write"],
"backend": {
"runtime": "node:20",
"build": "npm run build",
"start": "node dist/index.js"
},
"apps": [{ "key": "leads", "manifest": "apps/leads/workhub.json" }]
}Your server listens on process.env.PORT — the platform sets it.
No need to specify a port, database engine, version, or any other
infrastructure detail. Add "database": { "migrations_path": "migrations" }
if you want SQL migrations applied on deploy.
Supported runtimes: node:20, node:22, bun:1, python:3.11,
python:3.12. The platform pins each to a vetted alpine/slim base
image, runs your install command (auto-detected from the lockfile —
npm ci, pnpm install --frozen-lockfile, pip install -r ...),
runs your optional build, then sets start as the container entry.
The container always runs as a non-root workhub user.
After upload, the platform auto-builds the image, pushes to the registry,
records the tag on workspace_version, and queues the version for
operator review. You don't run docker build locally and you don't
call mark-built by hand — that's automated.
Pushing the bundle: one call, backend + every frontend
The workhub_push MCP tool (or the same packer over POST /v1/developer/workspaces/upload) ships the backend AND every app in manifest.apps[] in a single upload — there is no separate route for the backend image and no per-app upload. Add an app by appending an { key, manifest } entry to workspace.json#apps and dropping its prebuilt assets under apps/<key>/ — the next workhub_push picks it up.
The packer runs each app's build script then includes its built surface (dist/ if present, else the app dir minus src/, lockfiles, config files, source maps). The backend ships as source — the platform compiles the Dockerfile from backend.runtime and builds the image — while each app ships prebuilt because the platform serves the static assets directly and does not run a frontend build. BMS-ERP (in-tree) is the reference: one bundle, one push, one backend + a POS app + an ERP app.
The API key must carry apps.develop_external (or platform.admin) to push a workspace bundle. apps.develop alone is rejected on this route.
License
MIT
