Writing the backend function
Route shape, relaying platform errors, and why authorization is code you write against the verified caller.
The backend function handles everything under /api/* (and /_serverFn/* on TanStack). The
platform routes those paths to your backend function. Everything else is served as a static
file.
The shape
import { Hono } from "hono";
import { ApiError, ctx, db } from "@railcode/sdk";
const app = new Hono();
app.get("/api/me", (c) => c.json({ user: ctx.user }));
app.get("/api/notes", async (c) => {
const rows = await db.collection("notes").query()
.where("owner", "=", ctx.user!.id)
.order("updated_at", "desc")
.page(1, 100);
return c.json({ items: rows });
});
export default app;Authorization is your code
There is no scoped store that enforces ownership. db is one flat store. A key prefix is a
convention, and the check is what makes it real.
// ❌ the id came from the caller, so reading it proves nothing
const row = await db.collection("notes").get(c.req.param("id"));
return c.json(row);
// ✅ verify what you read against the verified caller
const row = await db.collection("notes").get(c.req.param("id"));
if (!row || row.owner !== ctx.user!.id) return c.json({ error: "not found" }, 404);Prefer 404 over 403 for records the caller should not know exist. A 403 confirms that the record exists.
Never trust a value from the request body for identity or ownership. Read it from
ctx.user. The one thing a backend function gets for free is a caller it can trust.
A rule like "X submits, Y approves, X can't approve their own" is backend code, in a place a user cannot reach:
app.post("/api/requests/:id/approve", async (c) => {
const user = ctx.user;
if (!user) return c.json({ error: "cron cannot approve" }, 409);
const req = await db.collection("requests").get(c.req.param("id"));
if (!req) return c.json({ error: "not found" }, 404);
if (req.submitted_by === user.id) return c.json({ error: "cannot approve your own" }, 403);
if (!user.is_admin) return c.json({ error: "approver role required" }, 403);
await db.collection("requests").put(req.id, {
...req, status: "approved", approved_by: user.id,
});
return c.json({ ok: true });
});Relay platform errors unchanged
When a route wraps an SDK call, catch ApiError and respond with its .status and body. The
frontend's 403/409/429 handling depends on the status surviving the extra hop. Collapsing it
into a 500 throws away every typed error the platform gives you.
async function relay<T>(c: Context, fn: () => Promise<T>) {
try {
return c.json(await fn());
} catch (err) {
if (err instanceof ApiError) {
let body: unknown;
try { body = JSON.parse(err.message); } catch { body = { detail: err.message }; }
return c.json(body, err.status);
}
throw err;
}
}
app.post("/api/charge", (c) =>
relay(c, () => connector("stripe").fetch("/v1/charges", { method: "POST" })));Then the frontend can act on meaning:
if (res.status === 409) showConnectPrompt(); // connector not linked / needs re-auth
if (res.status === 429) showQuotaNotice(); // daily cap
if (res.status === 403) showNotAllowed(); // undeclared authorityStatuses to handle by name:
| Status | Means |
|---|---|
403 | Undeclared authority. The manifest does not name this resource |
409 | Not connected, or the operation is incompatible with a cron trigger |
429 | A daily cap (typed quota) |
Handling a null caller
ctx.user is null on cron triggers. A route that reads ctx.user.roles or filters by
ctx.user.id will throw under cron. Or worse, it will return everything, because the flat
store enforces nothing.
if (!ctx.user) return c.json({ error: "cron cannot do this" }, 409);See scheduled work for the full trigger model.
Common pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
| Checking permissions only in the frontend | Anyone can curl /api/* from a logged-in session | Check again in the backend function against ctx.user |
| Taking a user id from the request body | Easy to spoof | Read ctx.user.id |
| Assuming a key prefix isolates data | It does not; db is flat | Verify the owner on read |
Using query() without paging | Silently drops everything past the first page | Loop until a short page |
A loop of files.url() | Uses up the subrequest budget | files.urls(names) |
A GET cron route | 404s on every fire | Accept POST |
Swallowing ApiError into a 500 | The UI cannot tell quota from forbidden | Relay .status unchanged |
A hand-rolled ReadableStream | A mid-stream failure vanishes; a hang-up keeps burning tokens | toNdjson(source) |
| A code-split or CJS backend module | Deploys, then crashes on the first request | One self-contained ES module |