Proposal: Custom HTTP Routes
Historical note: This proposal was written when Atomo had a WASM/JS plugin system. That system has been removed and replaced with actions & workers. Custom HTTP routes for workers are now served via the
/api/worker/crud/endpoints and direct action API (POST /api/actions/:name). The architectural ideas below may inform future route extensibility.
Status: Phase 2 implemented (registration + dispatch shipped; phase 3 transactional DB access still RFC) · Layer: Atomo core (
atomo_server+ the plugin runtime) · Pull trigger: a real consumer that today works around the gap with a separate process (e.g. a billing/metering sidecar).A first-class way for a plugin to register HTTP endpoints that
atomo-serverserves and dispatches to a sandboxed handler. This turns Atomo from fork-to-extend into extend-without-forking, and it lets business logic that needs a synchronous request/response with a transactional DB write live in Atomo instead of a bolted-on sidecar.
Where it fits
Atomo already exposes built-in routes — /graphql, /auth, /media, /realtime, /workflows, /audit — and an actions & workers system that hooks into the CRUD lifecycle via event-triggered automation.
What's missing is a way to own an endpoint. Today, adding a custom endpoint means editing atomo_server and recompiling — i.e. forking. Anything that needs its own URL + custom logic (a billing debit, a webhook receiver, a Store-receipt validator, an export endpoint) has to run as a separate process alongside Atomo. That's the gap this closes.
Why the existing plugin hooks aren't enough
The CRUD-hook + effect model is fire-and-forget and deferred: a plugin's dbQuery/http effects are fulfilled after the after-hooks run (see wasm_hooks.rs). There is no request the handler is responding to, and no way to do a read-modify-write inside one transaction and return a result. So:
- You can't reject "debit if balance ≥ cost" — the hook can
Abort, but it can't read the balance synchronously to decide. - You can't return a computed HTTP response (a receipt, a signed URL, a 402).
A custom route is the missing shape: synchronous handler, request in / response out, with transactional DB access.
Capability
A plugin declares one or more routes; atomo-server mounts them and dispatches matching requests to the plugin's handler:
- Registration — a plugin manifest (or a registration host call) declares
{ method, path, handler, auth }. - Dispatch — on a matching request, the server invokes the handler with the request (method, path params, query, headers, body) plus the verified auth context (reuse the JWT path), and returns the handler's
{ status, headers, body }. - Transactional DB access — the handler can run SQL in a real transaction (begin → read → conditional write → commit), so no-overdraw debits, idempotent upserts, etc. are correct and enforced inside Atomo.
- Sandboxed — same guarantees as today's plugins: WASM fuel metering, permission-gated DB/HTTP, no ambient filesystem.
client ──► atomo-server ──► [core routes: /graphql, /auth, /media, ...]
│
└─► /ext/<plugin>/<path> ──► plugin handler (JS/WASM)
• verified user injected
• synchronous request → response
• transactional dbQuery (begin/commit)The boundary (most important rule)
- Core data routes stay built-in.
/graphqlCRUD, auth, media, realtime are Atomo's; custom routes are for app/business logic on top. - Namespaced mount (e.g.
/ext/<plugin>/...) so plugin routes can never shadow or collide with core routes. - Durable outcomes still belong to the data layer. A route that mutates domain data should write through the normal model/event path where it can; raw transactional SQL is the escape hatch for things the schema can't express (and pairs with Schema Constraints).
Design principles
- Sync request/response, distinct from the deferred-effect model — this is the whole point.
- Reuse Atomo identity. The handler receives the verified principal; routes declare their auth requirement (public / authenticated / role).
- Sandboxed + permissioned, exactly like existing plugins (fuel, capability grants for DB/HTTP).
- Ergonomic first. JS handlers (via the embedded Javy/QuickJS runtime) for reach; WASM for hot paths.
What this obsoletes
The narrower "transactional command hook / conditional-append on create()" idea (raised by the metered-billing fit-gap) becomes unnecessary: with a custom route you write the transaction in your own handler. One general primitive replaces a family of special-purpose ones.
Implementation
Phase 2 lives in:
atomo_wasm_runtime::plugin—RouteDef { method, path, auth }+ aroutesfield onPluginManifest(parsed from[[routes]]inplugin.toml).atomo_server::wasm_plugins—WasmPluginManagerstores per-plugin routes, exposesplugin_routes(), andcall_route(plugin, request_json)runs the JS module with a{ "route": <request> }envelope and applies returned effects.atomo_server::plugin_routes—plugin_routes_router(manager, auth, routes)mounts each route at/ext/<plugin><path>, builds the request envelope (method/path/query/headers/body/principal), enforcesauthvia the existing JWT path, and maps the handler's{status, headers, body}to the HTTP response.atomo_server::servermerges that router alongside realtime/workflows.
The older native Plugin::register_routes(&self, router) -> Router hook (default no-op, in-process Rust only) is superseded by this WASM/JS-facing path — that was the seam users actually write against.
Phasing
- ✅ RFC + this doc.
- ✅ Route registration + dispatch (JS/Javy handlers first): manifest-declared
{method, path, auth}→ handler returning{status, headers, body}, mounted under/ext/<plugin>; verified principal injected. Shipped — see "Using it" below. - ✅ Transactional DB access in the handler — the piece that enables money/idempotency logic. Shipped: a handler returns a
transactionarray of{ sql, params, expect }statements that the server runs atomically in one DB transaction with bound params; anexpect(minRowsAffected) that fails rolls the batch back and returns an else-response — so a no-overdraw debit + idempotent ledger insert lives in the plugin, not a sidecar. RequiresWriteDatabase. Full spec + example in Custom Routes Phase 3 — Synchronous Transactional DB. - Harden: per-route auth + rate limits, request-size caps, timeouts, structured logs without PII; WASM handler support.
Using it (phase 2)
Declare routes in plugin.toml and handle them in the plugin's JS entry point.
# plugin.toml
name = "billing"
runtime = "js"
entry_point = "plugin.js"
[[routes]]
method = "POST"
path = "/debit"
auth = true # require a valid JWT; verified principal is injectedThe server mounts this at POST /ext/billing/debit. On a request it calls the plugin's JS module with a { "route": <request> } envelope and expects a { response, effects } object back:
// plugin.js — the module reads the envelope from stdin and writes JSON to stdout
function handle(env) {
const req = env.route; // { method, path, query, headers, body, principal }
if (req && req.route !== undefined) { /* ... */ }
const userId = req.principal && req.principal.id;
return {
response: { status: 200, headers: { "content-type": "application/json" },
body: { ok: true, user: userId, echo: req.body } },
// effects: [ { dbQuery: ... }, { emit: ... } ] // deferred (see phase 3)
};
}request.bodyarrives as parsed JSON when the body is JSON, else as a raw string.headersis a{ name: value }map;queryis the raw query string.- The handler's
response.status/response.headers/response.bodybecome the HTTP response (bodyis serialized to JSON unless it's already a string). - Effects are applied (permission-gated) after the handler returns — same model as the CRUD hooks. Synchronous reads mid-handler are not available yet (phase 3).
Open questions
- Registration: manifest (
plugin.toml) vs a runtime host call vs both. - Mount shape: fixed
/ext/<plugin>/*vs a configurable base path per plugin. - Transaction API exposed to handlers: explicit
begin/commit, or a "run these statements atomically" batch (safer, less rope). - JS-only to start, or WASM handlers from day one?
- Streaming/SSE responses — in scope, or v2?