Callbacks
Receive inbound external hits — OAuth redirects, webhooks — into plugin/app code via sealed single-use tokens and the /_system/callback endpoint.
A framework-owned way for an external service to hand control back to your app — an OAuth redirect, a webhook — and have it reach the right handler in your code, without any plugin or route defining a public endpoint for it.
Status: available in Arcway 0.3.0.
The problem
A plugin (say, Google) needs an external service to hit a URL and have that reach the plugin's code — the OAuth provider redirecting back, or a webhook POSTing in. But plugins have no HTTP routes. So: where does that inbound request land, and how does it get routed to the right handler?
The shape
The framework owns exactly one inbound endpoint:
/_system/callbackA caller — a plugin, or any route — registers what should happen with ctx.callbacks.register, gets an opaque token back, and hands that token to the external service. When the service comes back, the framework decrypts the token, looks up the registration, and dispatches.
1. Register
const token = await ctx.callbacks.register({
handler: 'google', // callbacks/google.js, or plugins/<id>/<name>
payload: { userId }, // arbitrary JSON, handed to the handler on dispatch
oneTime: true, // consume on first use (OAuth)
ttl: 600, // seconds
});This writes a row — { target, payload, oneTime, used, boundToSession, expiresAt } — to Arcway's internal callback table under a random id, and returns a sealed token: that id (+ expiry) authenticated-encrypted with a derived callback key (the same seal used for cookies). The token is fully opaque and tamper-proof, and small — the payload lives in the DB, so it fits inside OAuth's size-capped state.
For interactive one-time flows (OAuth) the row is stamped with boundToSession = ctx.req.session.id when a session exists — see CSRF below. Durable webhook registrations use oneTime: false and are not session-bound.
2. Hand the token to the provider
- OAuth — as the
stateparam.redirect_uri = /_system/callback(static, pre-registered);state = <token>. - Webhook — in the URL you register with the service:
/_system/callback/<token>.
3. Dispatch
The provider hits /_system/callback. The framework, in this order:
- Rate-limit (per-IP, strict) — before any crypto, so garbage-token spam can't exhaust CPU on the unseal.
- Unseal the token with the
callbackkey — a tampered or forged token fails here, before any DB work (no probing, no enumeration). - Atomic single-use claim (for
oneTime):UPDATE callbacks SET used = true WHERE id = ? AND oneTime = true AND used = false AND expiresAt > now. If no row is claimed, reject — already consumed, expired, or nonexistent. One statement does the claim, so there's no check-then-act window: a double-click or replay script can't run the handler twice. (Non-oneTime: select + expiry check.) - CSRF / session binding: if the row has a
boundToSession, assertctx.req.session.id === row.boundToSessionbefore dispatching. This stops OAuth login-CSRF — an attacker can't mint a token under their session, trick a victim into completing the flow, and bind the attacker's account to the victim. (Webhooks have no session; they skip this and rely on payload signature — below.) - Dispatch to the target handler with
ctx— it sees the full inboundreqand the storedpayloadasctx.callback.payload.
4. The handler
A callback handler is a normal route handler — same signature and return contract as an api/ route:
// callbacks/google.js (or a plugin handler)
export default async (ctx) => {
const { code } = ctx.req.query;
const { userId } = ctx.callback.payload;
const tokens = await exchangeCode(code);
await storeTokens(ctx, userId, tokens);
return { redirect: '/connected' };
};Handlers live in a top-level callbacks/ directory (discovered at boot, like api/); plugins can carry their own.
Why DB-backed + sealed
- Sealed → the token is opaque and tamper-proof, and forged tokens are rejected before touching the DB.
- DB-backed → the token stays tiny (fits OAuth
state), the payload can be rich, and single-use is enforced atomically by claiming the row.
Webhooks
The same machinery, with a durable (non-oneTime, long-TTL) token registered once with the external service. Two things differ:
- No session binding (there's no user session on an inbound webhook).
- The handler must still verify the provider's signature (Stripe/GitHub HMAC, etc.). The callback token routes the request to your handler — it does not authenticate the payload. A leaked webhook URL is otherwise perpetual access.
Lifecycle
An internal framework cron job (__system/cleanup-callbacks) periodically deletes expired rows and consumed one-time rows past a grace window, so the table doesn't grow unbounded.
Security properties
- One framework-owned inbound endpoint — no plugin- or route-defined public callback paths.
- Rate-limited → unsealed (authenticated decryption) → atomically single-use claimed → session-bound (for interactive flows) — before the handler runs. No forgery, no enumeration, no replay, no login-CSRF.
- Handlers run with the normal request
ctx; webhook handlers additionally verify the provider signature.
Plugins & Capabilities
Plugins let you package a slice of functionality — its handlers, jobs, tools, and UI — as a self-contained unit that Arcway discovers, loads, and wires into the rest of the app. Plugins are first-clas
Vault
Arcway's vault is an app's cryptographic backbone: it derives the framework's intrinsic secrets, and exposes a crypto toolkit on ctx.vault for your handlers. Everything starts from a single root key —