Arcwayv0.3.0

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/callback

A 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 state param. 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:

  1. Rate-limit (per-IP, strict) — before any crypto, so garbage-token spam can't exhaust CPU on the unseal.
  2. Unseal the token with the callback key — a tampered or forged token fails here, before any DB work (no probing, no enumeration).
  3. 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.)
  4. CSRF / session binding: if the row has a boundToSession, assert ctx.req.session.id === row.boundToSession before 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.)
  5. Dispatch to the target handler with ctx — it sees the full inbound req and the stored payload as ctx.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.

On this page