# Oven > A batteries-included backend framework for Bun. Infrastructure — auth, database, mail, > storage, queues — is configuration rather than wiring: add a brick, get a feature, fully typed. Oven is TypeScript-native and Bun-only. Routes are files, validation is [Standard Schema](https://standardschema.dev) so Zod and Valibot both work, errors are RFC 9457 problem+json, and an OpenAPI 3.1 document is generated from the same schemas that validate. Key things to know before writing code: - Handlers **return values**. There is no `res`, no `res.json()`, no `next()`. - Errors are thrown: `throw new NotFound('...')`. They become problem+json. - `ctx.db` is the **native** ORM client — a Drizzle instance, a Mongoose connection — not a wrapper. There is no Oven query API to learn. - Bricks contribute to the context. Leaving one out makes using it a compile error. - Express middleware does not work here. Oven middleware is `(ctx, next) => Response`. ======================================================================================== # Start here ======================================================================================== --- ## Installation Source: https://theoven.app/docs/start/installation/ > Get Oven running on Bun in under a minute. ## Requirements Bun 1.4 or newer. Oven is Bun-only by design — it uses `Bun.serve`, `Bun.file`, `Bun.S3Client`, `Bun.Image` and `bun:sqlite` directly rather than abstracting over them, which is what keeps the framework small and fast. There is no Node build and no plan for one. ```bash bun --version # 1.2.0 or higher ``` ## A new project ```bash bun create theoven my-app cd my-app bun install bun run dev ``` The scaffolder asks what you want and writes a configured, working app — no copy-pasted boilerplate. Answer with flags instead if you prefer: ```bash bun create theoven my-app --db sqlite --auth basic --yes ``` | Flag | Values | | | --- | --- | --- | | `--template` | `minimal`, `api` | `api` starts with a small REST API | | `--db` | `sqlite`, `postgres`, `none` | SQLite is Drizzle over `bun:sqlite` — no server to run | | `--auth` | `basic`, `none` | eight working endpoints at `/auth/*` | | `--no-openapi` | | skip the OpenAPI brick and `/docs` | | `--yes` | | accept every default | With a database, run the migrations the scaffold printed before starting: ```bash cp .env.example .env bun run db:generate && bun run db:migrate bun run dev ``` `create-oven` on npm belongs to someone else, and Bun maps `bun create ` strictly to `create-`. The package is [`create-theoven`](https://www.npmjs.com/package/create-theoven), matching the scope and the domain. The CLI itself is still `oven`. ## An existing project ```bash bun add @theoven/core bun add -d @theoven/cli ``` ```ts title="src/app.ts" export const app = createApp() await loadRoutes(app, `${import.meta.dir}/routes`) export default app ``` ```ts title="src/index.ts" await app.listen(3000) ``` Keep `listen()` out of `app.ts`. That split is what lets `oven routes`, `oven openapi`, `oven worker` and your tests import the app without binding a port. Then add whichever [bricks](https://theoven.app/docs/bricks/) you need — each is one `bun add` and one `.use()`. ## Configuration Bun loads `.env`, `.env.local` and `.env.` on its own, so there is nothing to install. Read them safely with `env.*`, or validate the whole set at boot with `defineEnv` — see [Environment variables](https://theoven.app/docs/reference/env/). ```ts const port = env.port('PORT', 3000) ``` A missing variable then fails at startup, naming itself, rather than at 3am as `undefined` somewhere unrelated. ## Contributing Oven's test suite is the contract. Run it first, so you know a failure is yours: ```bash git clone https://github.com/hiteshchoudhary/theoven.git cd theoven bun install bun test # every package bun run lint # Biome bun run typecheck bun run check:docs # every code sample on this site ``` `examples/kitchen-sink` registers every brick in one app and is the framework's integration test — if a change breaks how two bricks compose, it breaks there. ## Next [Your first route →](https://theoven.app/docs/tutorial/first-route/) --- ## Batteries included Source: https://theoven.app/docs/start/batteries/ > What you no longer install, wire, order, or forget — and what it costs when you do not use it. Here is a working Express app that parses JSON, reads cookies, accepts file uploads, logs requests, and does not crash on an async error: ```js const express = require('express') const cookieParser = require('cookie-parser') const multer = require('multer') const morgan = require('morgan') require('express-async-errors') const app = express() app.use(express.json()) app.use(express.urlencoded({ extended: true })) app.use(cookieParser(process.env.COOKIE_SECRET)) app.use(morgan('combined')) const upload = multer({ dest: '/tmp' }) app.post('/avatar', upload.single('file'), async (req, res, next) => { // …and you still have to remember `next(err)` in anything that predates the shim }) ``` Five dependencies, six lines of registration, and an ordering you have to get right — `json()` before your routes, `cookieParser` before anything reading a cookie. None of that was ever a decision. It was homework. Here is the same thing in Oven: ```ts export default defineRoute( { body: z.object({ file: z.file().max(5_000_000) }) }, async (ctx) => ctx.storage.upload(`avatars/${ctx.user.id}`, ctx.body.file), ) ``` ## What is always on Nothing to install, nothing to register, nothing to order. | | You get | You no longer install | | --- | --- | --- | | **Body parsing** | `ctx.body`, content-type aware: JSON, urlencoded, multipart, text, raw | `body-parser`, `express.json()` | | **File uploads** | web `File` objects, size and MIME limits, temp-file spill for large ones | `multer` | | **Cookies** | `ctx.cookies.get/set/delete`, signed cookies, secure defaults | `cookie-parser` | | **Query parsing** | `ctx.query`, with arrays and nested keys | `qs` | | **Token capture** | `ctx.token` from `Authorization`, the `token` cookie, or `?access_token=`, in that order | hand-rolled, every time | | **Request id + logging** | `ctx.id`, `ctx.log` — structured and request-scoped | `morgan` + `uuid` | | **Errors** | thrown errors become RFC 9457 problem+json; async errors caught | `express-async-errors` | | **Graceful shutdown** | SIGTERM drains in-flight requests, then closes brick resources | hand-rolled, every time | The rule: **if it appears in more than 80% of real apps, it goes in core with no switch.** Things that genuinely vary by app — CORS policy, rate limits, compression, security headers — still ship in the box, but as [middleware](https://theoven.app/docs/reference/middleware/) you configure. Configured, not installed. ## Always on, never paid for Batteries included is worthless if you pay for batteries you do not use. Nothing above is computed until you touch it. A route that returns a string parses no body, splits no cookie header, parses no query string, and generates no request id. `ctx.body` is a getter; so is `ctx.query`; so is `ctx.cookies`. Laziness is asserted by counting real reads of the underlying request in the test suite, and the probes were checked to flip when a getter is touched. An optimisation nobody measures is an optimisation that quietly stops being true. ## Why not middleware? Because `app.use(cookieParser())` is a bug in framework design, not a feature. It is a line every application writes, in the same place, for the same reason. Making it optional buys nobody anything: the framework still has to document it, every tutorial still has to include it, and the only outcomes are "you wrote it" and "you forgot and something broke oddly". A choice where one branch is always wrong is not a choice. The cost of getting this wrong compounds. Express's ordering rules exist because middleware is a list, and a list has an order someone must know. Oven has no such list for these behaviours — there is nothing to order because there is nothing to register. ## Where to go next - [Always-on batteries](https://theoven.app/docs/reference/batteries/) — the full API for each of the above - [Middleware & hooks](https://theoven.app/docs/reference/middleware/) — the configurable half: CORS, rate limits, compression, security headers - [Bricks](https://theoven.app/docs/bricks/) — the other kind of feature: add one, get a capability on the context - [Coming from Express](https://theoven.app/docs/guides/coming-from-express/) — a line-by-line translation --- ## Philosophy Source: https://theoven.app/docs/start/philosophy/ > Why Oven makes the choices it makes. ## 1. If every app needs it, it is not a brick Express asked you to assemble a working server from `body-parser`, `cookie-parser`, `multer`, `cors` and `morgan`. That was never a real choice. Nobody sat down and decided their API should *not* parse JSON bodies — they just had to remember, in the right order, forever. Oven ships those behaviours in core. Always on. Nothing to install, nothing to register, nothing to order, nothing to forget. The rule we apply: **if it appears in more than 80% of real apps, it goes in core with no switch.** Things that genuinely vary — CORS policy, rate limits, compression — stay configurable, but they still ship in the box. Configured, not installed. ```ts // Express app.use(express.json()) app.use(cookieParser()) app.use(multer().single('file')) // Oven — this is the whole file export default async (ctx) => { const { name } = await ctx.body // JSON, form or multipart, whichever arrived const files = await ctx.files() // uploads, as web File objects ctx.cookies.set('seen', '1', { httpOnly: true }) return { name, uploaded: Object.keys(files) } } ``` What you lose is the ability to *not* have those. In eleven years of Express nobody wanted that. What you gain is that they cannot be forgotten, cannot be ordered wrongly, and cannot be a different version in two services. ## 2. Always-on must be lazy Batteries included is worthless if you pay for batteries you never use. Nothing in Oven is computed until it is read. A route that returns a string never parses a body, never parses the query string, never touches cookies, never generates a request id, and never derives a logger. ```ts app.get('/ping', () => 'pong') // no URL parsed, no query parsed, no cookies read, no request id generated, no logger built ``` Every one of those is a getter that caches on first touch. `ctx.path` does not even construct a `URL` — it scans the string, because `new URL()` on every request costs more than the routing does. This is enforced by tests rather than good intentions: the suite asserts that reading the URL does not materialise a request id, and that setting a header does not either. Those tests exist because the property is invisible — nothing about a passing request tells you what it *didn't* do, so the only way to keep it true is to assert it. ## 3. Fresh start. We owe the past nothing. Oven is designed in 2026 for 2026. There is no Express compatibility layer, no `(req, res, next)` signature, no Connect middleware support, no CommonJS build, no callback API, and no Node stream interop. Every framework that offered an Express shim ended up shaped by Express, inheriting its mistakes along with its users. We would rather be honest: if you want Express, Express exists and is very good at being Express. What this means in practice: - **ESM only.** No `require`, no dual build. - **Web standards only.** `Request`, `Response`, `Headers`, `URL`, `FormData`, `ReadableStream`. - **Async only.** Every extension point returns a promise or a value. Nothing takes a callback. - **Bun only.** `Bun.serve`, `Bun.file`, `Bun.S3Client`, `bun:sqlite`, `bun:test`. ## 4. Types are the documentation If you need to read docs to discover that a property exists, we failed. Autocomplete on `ctx` should teach you the framework. This is why bricks contribute their types through registration: add storage and `ctx.storage` appears, fully typed. Leave it out and touching `ctx.storage` is a compile error, not a runtime crash at 3am. ```ts const app = createApp().use(db(drizzleSqlite({ schema }))) app.get('/users', (ctx) => ctx.db.select().from(users)) // ✓ typed from your schema app.get('/files', (ctx) => ctx.storage.list()) // ✗ compile error: no storage brick ``` It is also why `ctx.db` is the Drizzle client rather than something of ours wrapped around it. An invented query API would be permanently behind every ORM, worse than all of them, and unknown to every coding model — which knows Drizzle and Prisma cold. The same rule explains an absence: there is no `ctx.state` bag for middleware to stash things in. An untyped bucket is invisible to autocomplete and `unknown` at the point you read it. State that belongs on the context belongs to a [brick](https://theoven.app/docs/guides/writing-a-brick/), where it arrives typed. ## 5. Errors are values, not accidents A handler either returns a value or throws. Throwing `NotFound` produces a correct RFC 9457 `application/problem+json` response, whether the throw was synchronous or not. ```ts export default async (ctx) => { const user = await find(ctx.params.id) if (!user) throw new NotFound(`No user ${ctx.params.id}.`) return user } ``` ```json { "type": "about:blank", "title": "Not Found", "status": 404, "detail": "No user 8f14.", "instance": "/users/8f14" } ``` Errors we did not plan for are treated as bugs: they become a 500, and their message and stack are withheld in production. An unplanned error message is exactly where connection strings and internal hostnames leak. There is no `next(err)`, and no `express-async-errors` to remember. A rejected promise from a handler, a middleware, or a hook is caught the same way a synchronous throw is. ## 6. Two implementations, or it is not a contract Anywhere Oven meets something outside itself — a database, an auth provider, a mail service — the shape is a small contract package plus many adapters, with a `raw` escape hatch and capabilities declared at boot rather than discovered at runtime. The test of a contract is whether a **second, structurally different** implementation fits it without changes. `db-drizzle` alone proves nothing; `db-drizzle` plus `db-mongoose` proves it — and it was Mongoose that showed `transaction` could not be portable, so it became optional and declared rather than faked. The same reason the validator contract is exercised with Valibot and not only Zod. A contract must never invent. Where two providers genuinely disagree — Clerk hosts its sign-in page, better-auth mounts routes — the contract exposes the difference instead of giving one of them a method to fake. ======================================================================================== # Tutorial ======================================================================================== --- ## 1. Your first route Source: https://theoven.app/docs/tutorial/first-route/ > Create an Oven app, register a route, and understand the context object. Every Oven app starts with `createApp()`. ```ts title="src/index.ts" const app = createApp() app.get('/', () => ({ framework: 'oven', status: 'warm' })) app.listen(3000) ``` ```bash curl http://localhost:3000/ # {"framework":"oven","status":"warm"} ``` Returning an object gave you JSON with the right `Content-Type`. You did not serialise anything, and you did not touch a response object — because there isn't one. There is no `res`, and no `next`. A handler either returns a value or throws. That is the entire contract. It means handlers are ordinary functions you can call directly in a test. ## Route parameters Use `:name` for a path parameter. It arrives on `ctx.params`. ```ts app.get('/users/:id', (ctx) => { return { id: ctx.params.id } }) ``` ```bash curl http://localhost:3000/users/42 # {"id":"42"} ``` Parameters are always strings — that is what a URL contains. Turning `"42"` into a number is validation's job, and validation will do it for you in the next chapter. ## The context `ctx` is the single argument every handler receives. The properties you have today: | Property | What it is | | --- | --- | | `ctx.req` | The raw web-standard `Request`. Never wrapped, always available. | | `ctx.params` | Path parameters from the matched route. | | `ctx.url` | The parsed `URL`, built once and reused. | | `ctx.path` | The pathname, without parsing the full URL. | | `ctx.method` | The request method. | | `ctx.id` | A stable request id, adopted from `x-request-id` or generated. | | `ctx.log` | A logger with the request id already bound to every line. | | `ctx.ip` | The client address. | | `ctx.status` | Set it to choose the response status. | | `ctx.set(name, value)` | Set a response header. | | `ctx.redirect(to, status?)` | Build a redirect response. | Everything on that list is **lazy**. A handler that returns a constant string never generates a request id, never derives a logger, and never parses the URL. Reading nothing costs nothing. ## Setting status and headers ```ts app.post('/users', async (ctx) => { const body = await ctx.req.json() ctx.status = 201 ctx.set('location', '/users/3') return { id: '3', name: body.name } }) ``` Return `null` when there is nothing to say, and Oven sends a `204`: ```ts app.delete('/users/:id', () => null) // -> 204 No Content ``` ## What gets returned Oven coerces whatever you return, so handlers stay about your domain rather than about HTTP. | You return | You get | | --- | --- | | an object or array | `application/json` | | a string | `text/plain` | | a number or boolean | `application/json` (both are valid JSON documents) | | `null` or nothing | `204 No Content` | | `Bun.file(...)` | streamed from disk, content type detected | | a `ReadableStream` | streamed | | a `Response` | passed through untouched — you took control on purpose | | a `URL` | a `302` redirect | ## Methods you get for free Register a `GET` and you have also answered `HEAD` and `OPTIONS` correctly: ```bash curl -I http://localhost:3000/users # HEAD: GET's headers, no body curl -X OPTIONS -i http://localhost:3000/users # 204, Allow: GET, POST, OPTIONS curl -X PUT -i http://localhost:3000/users # 405, Allow: GET, POST ``` A wrong method returns `405` with a correct `Allow` header — not a lazy `404` that leaves the caller guessing whether the path or the verb was wrong. ## Next Right now `ctx.params.id` is an unvalidated string, and `await ctx.req.json()` gives you `any`. Next: [validation](https://theoven.app/docs/tutorial/validation/). --- ## 2. Validation Source: https://theoven.app/docs/tutorial/validation/ > One declaration that validates the request, types the handler, and will document the endpoint. ## One declaration, three jobs FastAPI's insight was that a single declaration should validate the request, type the handler, and document the endpoint. Python could do that with type hints because they survive to runtime. TypeScript's do not — so a schema takes their place and does the same three jobs. ```ts title="src/index.ts" app.post( '/users/:id', { params: z.object({ id: z.coerce.number() }), body: z.object({ name: z.string().min(1), email: z.email() }), }, async (ctx) => { ctx.params.id // number, coerced and validated ctx.body.email // string, guaranteed to be an email return db.user.update(ctx.params.id, ctx.body) }, ) ``` You wrote no generics and no interfaces. A bad request never reaches the handler. On a route with no body schema, `ctx.body` is a promise you await. Once a schema is declared, validation has already awaited and checked it — making you await a second time would be ceremony with no purpose, so the type changes to match. ## What can be validated | Key | Validates | | --- | --- | | `params` | path parameters | | `query` | the parsed query string | | `body` | the parsed request body | | `headers` | request headers, as a lowercase-keyed object | | `response` | the handler's result, by status code | ## Failures A validation failure is an RFC 9457 problem document with **every** problem listed: ```json { "type": "about:blank", "title": "Unprocessable Content", "status": 422, "detail": "Request validation failed.", "errors": [ { "location": "body", "path": "email", "message": "Invalid email address" }, { "location": "body", "path": "items[0].qty", "message": "Expected number, received string" }, { "location": "params", "path": "id", "message": "Invalid UUID" } ] } ``` Every location is checked before failing. Reporting only the first problem turns fixing a request into a guessing game, one field per round trip. Paths are rendered as `items[0].qty` rather than as a JSON array, so they can be pasted straight back into client code. Unlike an unexpected 500, these messages describe **the caller's own payload**, so they are shown in production too. Nothing internal leaks by telling someone their email is malformed. A body that is not valid JSON never reaches the schema. That is the caller sending nonsense, not a shape mismatch, and conflating the two makes both harder to debug. ## Coercion Everything in a URL is a string. Say so in the schema: ```ts { params: z.object({ id: z.coerce.number() }), query: z.object({ page: z.coerce.number().default(1), includeArchived: z.coerce.boolean().default(false), }), } ``` Oven does **not** silently coerce for you. It cannot know what you meant: turning `"123"` into `123` before the schema sees it would quietly break a `z.string()` field that legitimately holds digits — an order number, a zip code, a phone number. Being explicit costs seven characters and removes an entire category of surprise. ## Response schemas ```ts app.get('/users/:id', { response: { 200: UserSchema } }, (ctx) => db.user.find(ctx.params.id)) ``` A response that does not match is a **500**, not a 422: the caller did nothing wrong, your route drifted from its contract. In development the mismatch is described in the response; in production it is logged and withheld. Response validation is **on in development, off in production** by default. It checks your own code rather than untrusted input, so the bug it catches is one local development and CI should already have caught — and unlike request validation, the cost is paid on every success. Turn it on with `createApp({ validateResponses: true })` if you would rather pay it. ## Bring your own validator Oven accepts anything implementing [Standard Schema](https://standardschema.dev) — Zod, Valibot, ArkType, Effect Schema. You can even mix them on one route: ```ts app.post( '/users/:id', { params: z.object({ id: z.coerce.number() }), // zod body: v.object({ name: v.string() }), // valibot }, (ctx) => ({ id: ctx.params.id, name: ctx.body.name }), ) ``` Zod is the bundled default because it emits JSON Schema natively, which is what will make OpenAPI generation free. It is not a requirement — and the test suite validates a Valibot schema end to end so that claim stays true. ## Next [Errors](https://theoven.app/docs/tutorial/errors/). --- ## 3. Errors Source: https://theoven.app/docs/tutorial/errors/ > Throw an error, get a correct HTTP response — synchronously or not. ## Throwing is the error path There is no `next(err)`. Throw, and Oven turns it into the right response. ```ts app.get('/users/:id', (ctx) => { const user = users.get(ctx.params.id) if (!user) throw new NotFound(`No user with id ${ctx.params.id}`) return user }) ``` ```json title="404 response" { "type": "about:blank", "title": "Not Found", "status": 404, "detail": "No user with id 99" } ``` That is [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) `application/problem+json` — the standard shape for HTTP error bodies, so clients and gateways already understand it. ## Async throws are caught too ```ts app.get('/report', async () => { await Bun.sleep(10) throw new BadRequest('Date range is too wide') // caught, becomes 400 }) ``` Express needed `express-async-errors` for this, or every async handler needed its own `try/catch`. In Oven it is simply how errors work. ## The error classes | Class | Status | | --- | --- | | `BadRequest` | 400 | | `Unauthorized` | 401 | | `Forbidden` | 403 | | `NotFound` | 404 | | `MethodNotAllowed` | 405 | | `Conflict` | 409 | | `PayloadTooLarge` | 413 | | `UnsupportedMediaType` | 415 | | `UnprocessableContent` | 422 | | `TooManyRequests` | 429 | | `InternalServerError` | 500 | | `ServiceUnavailable` | 503 | ## Adding detail and headers `detail` is a bag of **extra members merged into the problem document**, not the RFC's `detail` string — that one comes from the message. This is exactly how validation reports fields: ```ts throw new UnprocessableContent('Validation failed', { detail: { errors: [{ path: 'email', message: 'Invalid email address' }] }, }) ``` ```json title="422 response" { "type": "about:blank", "title": "Unprocessable Content", "status": 422, "detail": "Validation failed", "errors": [{ "path": "email", "message": "Invalid email address" }] } ``` Note where `errors` landed: at the top level, beside `status`. RFC 9457 allows extension members there, and putting them one level down would mean every client digging through a wrapper. Headers travel with the error, which is how a `429` carries `Retry-After`: ```ts throw new TooManyRequests('Slow down', { headers: { 'retry-after': '30' } }) ``` ## Unexpected errors are treated as bugs Anything thrown that is *not* an `OvenError` becomes a `500`. In development you see the real message. In production you do not: ```ts app.get('/boom', () => { throw new Error('postgres://user:hunter2@db-primary/app') }) ``` ```json title="production response" { "type": "about:blank", "title": "Internal Server Error", "status": 500 } ``` The original error still reaches your logs in full, including the stack. The operator gets the detail; the internet does not. An unplanned error message is exactly where connection strings and internal hostnames leak — so Oven has a test asserting that this specific string cannot appear in a production response. ## Customising the output ```ts const app = createApp({ onError: (error, ctx) => { ctx.log.warn('request failed', { status: error.status }) return { ok: false, code: error.status } }, }) ``` Return `undefined` to fall back to the default rendering. If your handler itself throws, Oven logs that and still renders the original error — a broken error handler must not hide the problem it was meant to describe. ## Catching them in tests An `OvenError` is an ordinary error until it reaches the response, so a unit test can assert on it directly: ```ts expect(() => parseRange('2020-2050')).toThrow(BadRequest) const response = await app.fetch(new Request('http://x/users/99')) expect(response.status).toBe(404) expect(await response.json()).toMatchObject({ status: 404, title: 'Not Found' }) ``` ## Defining your own Subclass `OvenError`. It takes a status, a title, an optional message and an options object: ```ts export class PaymentRequired extends OvenError { constructor(message = 'Payment required.') { super(402, 'Payment Required', message, { // A resolvable URI documenting this specific failure. RFC 9457's `type` is the field // clients are meant to branch on — unlike a status, it can be specific to your API. type: 'https://acme.dev/problems/payment-required', detail: { upgradeUrl: '/billing' }, }) } } ``` ```json title="402 response" { "type": "https://acme.dev/problems/payment-required", "title": "Payment Required", "status": 402, "detail": "Payment required.", "upgradeUrl": "/billing" } ``` The built-in classes are made the same way. `Unauthorized` also carries a default `WWW-Authenticate: Bearer` header, because RFC 9110 says a 401 must say *how* to authenticate and leaving that to each throw site means most of them forget. ## Next [Database →](https://theoven.app/docs/tutorial/database/) Or jump to the full [error reference](https://theoven.app/docs/reference/errors/), or build something end to end in [Build a todo API](https://theoven.app/docs/build/). --- ## 4. A database Source: https://theoven.app/docs/tutorial/database/ > Add a brick, get a typed client on the context — and keep writing ordinary Drizzle. So far the routes have stored things in a `Map`. Time to make them real. ## Add the brick ```bash bun add @theoven/db @theoven/db-drizzle drizzle-orm bun add -d drizzle-kit ``` ```ts title="src/schema.ts" export const notes = sqliteTable('notes', { id: text('id').primaryKey(), title: text('title').notNull(), body: text('body'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), }) ``` ```ts title="src/app.ts" export const app = createApp().use(db(drizzleSqlite({ url: './data.db', schema }))) ``` ```bash oven db generate # writes a migration from the schema oven db migrate # applies it ``` That is the whole setup. No connection code, no pool to manage, no shutdown hook to remember. ## `ctx.db` is Drizzle ```ts title="src/route.ts" export const route = routesFor() ``` Declare that once, and every route file can see what the app registered. Then: ```ts title="src/routes/notes/index.get.ts" export default route( { query: z.object({ limit: z.coerce.number().int().max(100).default(20) }) }, (ctx) => ctx.db.select().from(notes).orderBy(desc(notes.createdAt)).limit(ctx.query.limit), ) ``` `ctx.db` **is** the Drizzle client, typed from your schema. Not a wrapper, not a repository, not an Oven query API. Autocomplete on it is Drizzle's autocomplete; an error from it is Drizzle's error; a question about it is answerable from Drizzle's documentation. Because it would be permanently behind every ORM and worse than all of them. It would also throw away the thing that makes this pleasant to write with a coding agent: a model knows Drizzle cold, and would know an invented Oven API not at all. ## Writing ```ts title="src/routes/notes/index.post.ts" export default route( { body: z.object({ title: z.string().min(1).max(200), body: z.string().optional() }) }, async (ctx) => { const [note] = await ctx.db .insert(notes) .values({ id: crypto.randomUUID(), title: ctx.body.title, body: ctx.body.body ?? null, createdAt: new Date(), }) .returning() ctx.status = 201 return note }, ) ``` ## Not found is thrown, not returned ```ts title="src/routes/notes/[id].get.ts" export default route({ params: z.object({ id: z.uuid() }) }, async (ctx) => { const [note] = await ctx.db.select().from(notes).where(eq(notes.id, ctx.params.id)) if (!note) throw new NotFound(`No note with id ${ctx.params.id}.`) return note }) ``` A malformed id never reaches the query: `z.uuid()` rejects it as a `422` first. The handler only ever runs on input that already type-checks at runtime. ## Transactions ```ts await transaction(ctx.db, async (tx) => { await tx.insert(notes).values(note) await tx.update(counters).set({ total: next }) }) ``` Portable across providers. Drizzle's own `ctx.db.transaction(...)` works too, and you should use it when you want Drizzle's options. For a route where a half-written request is worse than a failed one, wrap the whole thing: ```ts app.use('/admin', transactional()) ``` Applied globally, that holds a transaction open for the length of every request — including the slow ones calling an external API, which is how a connection pool runs out. Put it on the routes that write. ## Moving to Postgres ```ts export const app = createApp().use( db(drizzlePostgres({ url: env.string('DATABASE_URL'), schema })), ) ``` Change the import, change the line, change `dialect` in `drizzle.config.ts`. **Every query above is unchanged** — that is the entire reason the default is Drizzle over `bun:sqlite` rather than raw SQL. Raw SQL strings are dialect-bound, and a raw default would mean rewriting every query the day you outgrow SQLite. Prefer MongoDB? [`db-mongoose`](https://theoven.app/docs/bricks/db-mongoose/) puts a Mongoose `Connection` on `ctx.db` through the same contract. ## Next [Auth →](https://theoven.app/docs/tutorial/auth/) — signup, login and guarded routes, without writing any of it. --- ## 5. Auth Source: https://theoven.app/docs/tutorial/auth/ > Signup, login and guarded routes — eight endpoints you do not write. Authentication is the part of a project most likely to be written badly, because it is the part everyone writes from memory at the end of a long day. So Oven ships it. ## Add the brick ```bash bun add @theoven/auth @theoven/auth-basic ``` ```ts title="src/app.ts" // One connection, shared: auth-basic builds its store before the app exists, and two // connections to `:memory:` would be two separate databases. const sqlite = new Database('./data.db') const client = drizzle(sqlite, { schema }) export const app = createApp() .use(db(drizzleSqlite({ client: sqlite, schema }))) .use(auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') }))) ``` ```ts title="src/schema.ts" // Your tables, plus the three auth-basic owns — so `oven db generate` writes migrations for // them too. Without this line, signup fails at runtime with "no such table: auth_users". export * from '@theoven/auth-basic/schema' ``` ```bash oven db generate && oven db migrate ``` ## What you just got Eight endpoints, mounted at `/auth`: | | | | --- | --- | | `POST /auth/signup` | name, email, password | | `POST /auth/login` | returns an access token, sets a refresh cookie | | `POST /auth/refresh` | rotates both | | `POST /auth/logout` | genuinely revokes the session | | `GET /auth/me` | the signed-in user | | `POST /auth/change-password` | signs every other session out | | `POST /auth/forgot-password` | emails a single-use link | | `POST /auth/reset-password` | redeems it | ```bash curl -X POST localhost:3000/auth/signup \ -H 'content-type: application/json' \ -d '{"name":"Ada","email":"ada@example.com","password":"correct-horse"}' ``` There is no `AUTH_SECRET` default, and the brick refuses to construct without one. A framework that invents a signing secret has invented a secret every deployment shares. ## Guarding a route ```ts title="src/routes/notes/index.post.ts" export default route( { auth: true, body: z.object({ title: z.string().min(1) }) }, async (ctx) => { const [note] = await ctx.db .insert(notes) .values({ id: crypto.randomUUID(), authorId: ctx.user.id, title: ctx.body.title, createdAt: new Date() }) .returning() ctx.status = 201 return note }, ) ``` Two things worth noticing. `ctx.user.id` — no null check. The guard runs before the handler, so a route declaring `auth: true` is unreachable anonymously, and TypeScript **narrows `ctx.user` to non-null inside it**. Delete `auth: true` and that line stops compiling. The route says nothing about tokens. Where the credential came from — an `Authorization` header, the `token` cookie, or `?access_token=`, in that order — is core's business, and which provider validated it is the brick's. ## More than "signed in" ```ts title="src/app.ts" const policies = { admin: (user) => user.raw.role === 'admin', owner: (user, ctx) => ctx.params.id === user.id, } app.use(auth(basicAuth({ db: client, secret }), { policies })) ``` ```ts export default route({ auth: 'admin' }, (ctx) => deleteEverything()) ``` A policy is a plain function: testable, greppable, and named in the OpenAPI document — a guarded operation carries `security`, a `401`, and for a named policy its name and a `403`. Because roles are not normalised across providers. A portable `role` guard would fail silently on Clerk, or on any provider that models permissions differently. A function you wrote cannot fail that way — and when it does fail, you can read it. ## Password reset, working today ```ts basicAuth({ db: client, secret: env.string('AUTH_SECRET'), sendResetEmail: async (to, token) => { await mailer.send({ to, subject: 'Reset your password', text: `${config.appUrl}/reset?token=${token}` }) }, }) ``` With the [`mail`](https://theoven.app/docs/bricks/mail/) brick registered and no provider configured, the link prints to your terminal — so the whole flow works before you have signed up for anything. Read it at `/_oven/mail`. ## What it does that you would have forgotten - **Login does not reveal which emails exist.** A wrong password and an unknown address return identical bodies, identical statuses, and comparable timing — an unknown email still pays a full argon2 verification against a decoy hash. - **Logout genuinely revokes.** The access token is short-lived; the refresh token is a database row that gets deleted. A stateless-only design would make logout a suggestion. - **Reset tokens are single-use, expiring, and stored hashed.** Redeeming one signs every session out. - **Changing a password signs every other session out**, because someone changing a password usually believes an account is compromised. - **Login, signup and reset are rate limited** by default, on both IP *and* email — either key alone leaves a hole. Each of those has a test, including the enumeration and token-reuse cases. ## A different provider The contract is the same whichever you pick: | | | | --- | --- | | [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) | email and password, on Drizzle | | [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) | the same flows, on Mongoose | | [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/) | Clerk-hosted sign-in; mounts nothing | | [`auth-better`](https://theoven.app/docs/bricks/auth-better/) | better-auth, with all its own routes | `auth: true` and your policies keep working across all four. What changes is one line in `app.ts`. ## Next - [Testing](https://theoven.app/docs/guides/testing/) — including a password-reset flow, token and all - [Deployment](https://theoven.app/docs/guides/deployment/) — and what production refuses to boot with ======================================================================================== # Guides ======================================================================================== --- ## Benchmarks Source: https://theoven.app/docs/guides/benchmarks/ > Numbers you can reproduce, including the ones where we lose. Every number here comes from a script in the repo. Run them yourself — numbers nobody can reproduce are marketing, not measurement. ```bash bun benchmarks/http.bench.ts # real sockets, all four frameworks bun benchmarks/dispatch.bench.ts # Request -> Response, no socket bun benchmarks/router.bench.ts # router lookup alone bun benchmarks/image.bench.ts # Bun.Image, and the cost of the upload guard ``` Recorded on **Bun 1.4.0** / Node 22.22, Apple Silicon, 2026-08-21. ## HTTP throughput Real sockets, 50 connections, `autocannon`, identical routes. Each framework runs on the runtime it is actually deployed on. | framework | runtime | static text req/s | param + json req/s | p99 | | --- | --- | ---: | ---: | ---: | | hono | bun | 94,765 | 95,911 | 1 ms | | **oven** | **bun** | **93,805** | **94,087** | **1 ms** | | fastify | node | 80,084 | 77,824 | 1 ms | | express | node | 18,155 | 18,072 | 5 ms | Oven is about **5.2× Express** and **1.21× Fastify**, and level with Hono — 1.9% behind on the parameterised route and 1.0% behind on static text, both inside run-to-run variance. Every row here is lower than the previous run. Express and Fastify did not change runtime — same Node 22.22, same code — and moved −1.7% and −3.8%, which sets the floor for how much this machine drifts between sessions. Both Bun frameworks fell roughly three times further, and fell together. We are not going to call that a Bun regression on the strength of one laptop running its own load generator, and we are not going to restate the numbers as though nothing moved. The ranking is unchanged and the Oven-to-Hono gap is unchanged. The [benchmarks README](https://github.com/hiteshchoudhary/theoven/blob/main/benchmarks/README.md) has the before-and-after table. ## Dispatch overhead `Request` in, `Response` out, with no socket involved. Express and Fastify cannot appear here — they are built on Node's `req`/`res` and have no `Request` entry point. | scenario | oven | hono | on Bun 1.2.23: oven | hono | | --- | ---: | ---: | ---: | ---: | | static root | 1051 ns | 406 ns | 1258 ns | 1086 ns | | param + json | 1255 ns | 916 ns | 1519 ns | 1838 ns | | 404 miss | 1228 ns | 956 ns | 1530 ns | 1922 ns | **This page used to say Oven dispatched faster than Hono on two of these three rows. On Bun 1.4 that is no longer true.** Oven got about 17% faster; Hono got 2.0–2.7× faster and now leads every scenario. The likely cause is in Bun's own release notes: 1.4 rewrote the RegExp engine, and Hono's router is regular-expression driven where ours walks a radix tree by hand. A runtime improvement landing squarely on a competitor's hot path is still a real result. A framework that compiles each route into a specialised function at boot — with `new Function`, leaving no generic dispatch on the hot path — beats both of these columns comfortably. Oven interprets the same structure on every request. Adopting that technique is tracked as a post-1.0 item; doing it before the brick and context contracts settle would mean writing the compiler twice. ## Read both tables together This is the point: the two tables do not rank the same way. Hono dispatches **2.6× faster** on a static root and **1.37× faster** on a parameterised route. At the socket, those advantages collapse to **1.0%** and **1.9%**. Once a real connection exists, syscalls and TCP dominate and several hundred nanoseconds of framework overhead vanishes. The dispatch table is a useful signal about where our own overhead lives, and a bad basis for choosing a framework. We publish both, because publishing only the flattering one would be dishonest — and publishing only the microbenchmark would mislead in the other direction. That was an easy sentence to write when the microbenchmark flattered us. It is the same sentence now that it does not. ## Reproducing them ```bash git clone https://github.com/hiteshchoudhary/theoven.git cd theoven && bun install bun benchmarks/http.bench.ts ``` The harness starts each server as a **separate process**, waits for it to answer, runs `autocannon` against it, and shuts it down before the next — so no framework is measured while another holds memory or a JIT-warm CPU. Node contenders run under `node`, Bun contenders under `bun`, because comparing a framework on a runtime nobody deploys it to measures nothing. Every server implements the same two routes and returns the same bytes: ```ts title="benchmarks/servers/oven.server.ts" const app = createApp({ logger: silentLogger }) app.get('/', () => 'ok') app.get('/users/:id', (ctx) => ({ id: ctx.params.id })) app.listen(Number(Bun.env.PORT)) ``` Numbers will differ on your machine — laptops thermally throttle, and the load generator shares a CPU with the server. What should hold is the **ordering** and the rough ratios. If it does not, that is worth an issue. ## What none of this measures Every framework here is fast compared to a 2 ms database query. Oven is not trying to win a router microbenchmark; Bun makes everything fast. What we compete on is the time between `bun create` and an app shaped like production — and no benchmark measures that. Caveats worth knowing: loopback only, empty handlers, and the load generator shares a CPU with the server, which compresses the top of the table. Treat anything above 90k req/s as "at the ceiling". ## Image processing `Bun.Image`, which is what [the image brick](https://theoven.app/docs/bricks/image/) is built on. The number that matters is not how fast a resize is — it is how much cheaper reading a header is than decoding the pixels, because that ratio is the whole basis for guarding an upload. | source | on the wire | header read | resize + encode | | --- | ---: | ---: | ---: | | 800×600 (0.5 MP) | 3 KB | 0.8 µs | 3.8 ms | | 2000×1500 (3.0 MP) | 15 KB | 0.4 µs | 7.7 ms | | 4000×4000 (16 MP) | 70 KB | 0.6 µs | 44.4 ms | Reading the header is **constant time**; decoding is linear in pixels. A 70 KB request body carrying 16 megapixels is refused in well under a microsecond, and the gap only widens as the attack gets bigger — which is why a byte limit alone is not a defence. Image work also does not block the event loop: a 110 ms resize saw 87 timer ticks. A resize in a handler costs that request its latency, not the whole process's. --- ## Coming from Express Source: https://theoven.app/docs/guides/coming-from-express/ > A translation guide — deliberately not a compatibility layer. Oven has no Express compatibility layer and will not get one. Every framework that shipped an Express shim ended up shaped by Express. Your middleware will not run here; your mental model mostly will. ## The handler signature ```js title="Express" app.get('/users/:id', (req, res) => { res.status(200).json({ id: req.params.id }) }) ``` ```ts title="Oven" app.get('/users/:id', (ctx) => { return { id: ctx.params.id } }) ``` No `res`. Return a value; Oven works out the rest. `200` and `application/json` are what returning an object means. ## Middleware you can delete | Express | Oven | | --- | --- | | `express.json()` / `body-parser` | `ctx.body`, always on, lazy | | `cookie-parser` | `ctx.cookies`, always on | | `multer` | uploads arrive as `File` in `ctx.body` | | `express-async-errors` | async throws are caught natively | | `morgan` | `ctx.log`, request-scoped and structured | | hand-rolled bearer-token parsing | `ctx.token` | | hand-rolled graceful shutdown | built into `close()` | ## Middleware The signature is the biggest change, and the one to learn first. ```js title="Express" app.use((req, res, next) => { const started = Date.now() res.on('finish', () => console.log(Date.now() - started)) next() }) ``` ```ts title="Oven" app.use(async (ctx, next) => { const started = performance.now() const result = await next() ctx.log.info('handled', { ms: performance.now() - started }) return result }) ``` Express middleware is a **list** you call `next()` to advance. Oven middleware is an **onion**: `next()` returns, so the code after it runs on the way back out. That is why timing, logging and header-stamping need no `res.on('finish')` — there is an "after" to write code in. What `next()` gives you is the **result**, not necessarily a `Response`: a handler returning an object hands you that object, and coercion happens once at the end. Framework refusals — `404`, `405` — do arrive as a `Response`, which is what lets your headers land on them too. Pass it through unchanged unless you mean to replace it, and use `ctx.set()` for headers rather than rebuilding the response. Scoping works the same way: ```ts app.use('/admin', requireAdmin()) // this prefix and below ``` Middleware runs for requests that match **no** route, which Express users often expect and do not get — so CORS preflights and 404s still pass through your logging and security headers. ## Routers ```js title="Express" const users = express.Router() users.get('/', list) users.get('/:id', show) app.use('/users', users) ``` There are two translations, and both are right depending on why you had a router. **If the router was a directory of endpoints**, use [file-based routing](https://theoven.app/docs/reference/file-routing/) — the filesystem is the route table: ```ts title="Oven — file routing" // src/routes/users/index.get.ts → GET /users // src/routes/users/[id].get.ts → GET /users/:id ``` A `_middleware.ts` in a directory applies to it and everything under it, which is what `router.use()` was for. **If the router was a group with something in common** — a shared guard, a shared prefix, a set of routes you mount twice — use a [router](https://theoven.app/docs/reference/routers/): ```ts title="Oven — a router" const users = routerFor({ prefix: '/users', tags: ['users'], auth: true }) users.get('/', list) users.get('/:id', { params: z.object({ id: z.uuid() }) }, show) app.use(users) ``` That is closer to what `express.Router()` actually was, with two differences worth knowing: - **`auth` and `tags` are declared once for the group.** Express had no equivalent; you passed middleware and hoped every route in the file was covered. - **A router can be mounted more than once**, so `/v1` and `/v2` over the same routes is two lines rather than a factory function. | Express | Oven | | --- | --- | | `express.Router()` | `router()` / `routerFor()` | | `router.use(mw)` | `router.use(mw)` — scoped to the router's prefix | | `app.use('/users', router)` | `router({ prefix: '/users' })`, then `app.use(router)` | | `router.use(otherRouter)` | `router.use(otherRouter)` — prefixes join | | middleware for auth on every route | `auth` on the router, once | | `app.use('/api', someOtherApp)` | not supported — a router is routes, not a second app | ## Validation ```js title="Express" const { body, validationResult } = require('express-validator') app.post('/users', body('email').isEmail(), body('age').isInt({ min: 0 }), (req, res) => { const errors = validationResult(req) if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }) // req.body is still `any` }, ) ``` ```ts title="Oven" export default route( { body: z.object({ email: z.email(), age: z.number().int().min(0) }) }, (ctx) => ctx.body.email, // typed, already validated ) ``` One declaration replaces the chain, types the handler, **and** becomes the endpoint's OpenAPI description. See [Validation](https://theoven.app/docs/reference/validation/). ## Errors ```js title="Express" app.get('/users/:id', async (req, res, next) => { try { const user = await db.find(req.params.id) if (!user) return res.status(404).json({ error: 'Not found' }) res.json(user) } catch (err) { next(err) } }) ``` ```ts title="Oven" app.get('/users/:id', async (ctx) => { const user = await db.find(ctx.params.id) if (!user) throw new NotFound() return user }) ``` The `try/catch` and the `next(err)` are gone because the framework already does that. The `404` body is a standard problem document instead of an ad-hoc shape you invented. ## Status and headers ```js title="Express" res.status(201).set('Location', '/users/3').json(user) ``` ```ts title="Oven" ctx.status = 201 ctx.set('location', '/users/3') return user ``` ## Sending files ```js title="Express" res.sendFile('/path/to/report.pdf') ``` ```ts title="Oven" return Bun.file('/path/to/report.pdf') // streamed, content type detected ``` ## CORS, helmet, rate limiting ```js title="Express" app.use(cors({ origin: 'https://app.example.com' })) app.use(helmet()) app.use(rateLimit({ windowMs: 60_000, max: 100 })) ``` ```ts title="Oven" app.use(cors({ origin: 'https://app.example.com' })) app.use(securityHeaders()) app.use(rateLimit({ limit: 100, window: 60_000 })) ``` Nothing to install — these ship in core. They stay explicit rather than always-on because their *policy* genuinely varies by app, unlike body parsing. ## `req.user`, `res.locals` and friends Express middleware attaches things to the request and later handlers read them, untyped: ```js title="Express" app.use(async (req, res, next) => { req.tenant = await resolveTenant(req.hostname) next() }) app.get('/dashboard', (req, res) => res.json({ tenant: req.tenant })) // any ``` Oven has no `ctx.state` bag, deliberately — an untyped bucket is invisible to autocomplete and `unknown` where you read it. There are two replacements, and which one you want depends on scope: ```ts title="Oven — needed by some routes" const tenant = dependency('tenant', (ctx) => resolveTenant(ctx.header('host'))) export default route({ deps: { tenant } }, (ctx) => ({ tenant: ctx.deps.tenant })) // ^ typed ``` ```ts title="Oven — needed by every route" // A brick: built once at boot, on the context of every request. app.use({ name: 'billing', setup: () => createBillingClient() }) ``` The rule: [a dependency](https://theoven.app/docs/reference/dependencies/) is per request and per route, [a brick](https://theoven.app/docs/reference/bricks/) is per app. Both arrive typed; neither can be forgotten by a route that needs it, because the route names it. Dependencies also compose and clean up, which `req.locals` never did: ```ts const tx = dependency('tx', async function* (ctx) { const handle = await begin(ctx.db) try { yield handle await handle.commit() } catch (error) { await handle.rollback() throw error } }) ``` ## Things that genuinely do not translate - **Connect middleware.** The `(req, res, next)` signature does not exist. Anything from the Express ecosystem must be rewritten as an Oven brick or a `_middleware.ts` file. - **`res.write()` streaming.** Return a `ReadableStream` instead. - **CommonJS.** Oven is ESM only. - **Running on Node.** Oven is Bun-only. This is a deliberate, permanent choice. - **`express.static()`.** There is no static-file middleware. Return `Bun.file(path)` from a route, or — better for anything public — put a CDN or your platform's static hosting in front. - **View engines.** No `res.render`, no Pug or EJS integration. Return HTML from a template literal, or a `Response` you built yourself. - **`req.session`.** No session middleware. The [`auth`](https://theoven.app/docs/bricks/auth/) brick issues tokens rather than server-side sessions; `ctx.cookies` is there if you want to build your own. ## Porting an app, in order 1. **Start a new project** with `bun create theoven my-app` rather than converting in place. The directory layout is the router, so a half-converted tree is harder to reason about than two whole ones. 2. **Move routes first**, one file per endpoint. Handlers usually port by deleting things: the `try/catch`, the `next(err)`, the `res.json`. 3. **Delete middleware rather than porting it.** Check the table above first — most of what an Express app registers is already on. 4. **Add schemas as you go.** Each one you write buys typing and a documented endpoint, so this is the step that pays for itself fastest. 5. **Swap the infrastructure last** — database, auth, uploads, jobs. Each is one `bun add` and one `.use()`; see the [brick catalogue](https://theoven.app/docs/bricks/). ## What you gain The reason to move is not the handler signature — it is that auth, database, storage, mail and queues become configuration instead of a week of wiring. See the [philosophy](https://theoven.app/docs/start/philosophy/) page for why that is the whole point. --- ## Deployment Source: https://theoven.app/docs/guides/deployment/ > Build it, put it in a container, and know what to check before you call it done. ## Build ```bash oven build ``` Two things happen, in this order: 1. A **route manifest** is written to `.oven/routes.ts` with a static import per route file, so production neither walks the filesystem at boot nor imports anything dynamically. 2. `Bun.build` bundles the app, targeting Bun. Output is a single `dist/index.js` — dependencies included. ```bash oven start # runs dist/index.js bun dist/index.js # the same thing, no CLI needed at runtime ``` That second line is the useful one for a container: the built output has no dependency on `@theoven/cli`, so a production image does not need it. ## Dockerfile ```dockerfile FROM oven/bun:1 AS build WORKDIR /app # Dependencies first, so a code change does not re-download them. COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . RUN bunx oven build FROM oven/bun:1-slim WORKDIR /app # Only the bundle. No node_modules, no sources, no CLI. COPY --from=build /app/dist ./dist # Never run as root. USER bun EXPOSE 3000 CMD ["bun", "dist/index.js"] ``` The naming collision is unfortunate and entirely coincidental — `oven-sh` is Bun's GitHub organisation. `oven/bun:1` is the official Bun image. If your app uses `bun:sqlite` with a file, mount a volume for it. A database inside the image is a database that resets on every deploy. ## Configuration Read everything through [`env`](https://theoven.app/docs/reference/env/), in one module, at boot: ```ts title="src/env.ts" export const config = { port: env.port('PORT', 3000), databaseUrl: env.string('DATABASE_URL'), authSecret: env.string('AUTH_SECRET'), } ``` A missing variable then fails at **startup**, with a message naming it — instead of at 3am as `undefined` somewhere unrelated. That is the whole reason the readers throw rather than returning a default. `oven doctor` checks the same things before you deploy, and says what to do about each. ## Production refuses development defaults Four bricks deliberately fail to boot outside development rather than working badly: | Brick | Refused | Escape hatch | Because | | --- | --- | --- | --- | | [`mail`](https://theoven.app/docs/bricks/mail/) | the `console` driver | `allowConsoleInProduction` | reset links printed to a log look healthy while nobody can reset their password | | [`storage`](https://theoven.app/docs/bricks/storage/) | the `disk` driver | `allowDiskInProduction` | uploads on a container filesystem vanish on the next deploy | | [`queue`](https://theoven.app/docs/bricks/queue/) | the `memory` driver | `allowMemoryInProduction` | a deploy silently drops every queued email | | [`cache`](https://theoven.app/docs/bricks/cache/) | the `memory` driver | `allowMemoryInProduction` | each instance keeps its own copy, so a user refreshes and sees a different answer | The common shape: each one **works perfectly on a laptop** and fails only under conditions you cannot reproduce there — more than one instance, or a restart. That is the bug class worth a boot error, because no amount of local testing finds it. Each failure names itself and says what to configure. The escape hatches are real, not discouragement: a per-instance cache for a hot lookup table is a legitimate choice, and `allowMemoryInProduction` is how you say so deliberately rather than by accident. Oven is in production mode when `NODE_ENV=production`, or when you pass `createApp({ development: false })`. ## Health checks and shutdown ```ts title="src/routes/health.get.ts" export default () => ({ status: 'healthy' }) ``` For a check that means something, ask the database: ```ts export default async (ctx) => { const database = await checkHealth(ctx.db) ctx.status = database ? 200 : 503 return { status: database ? 'healthy' : 'degraded', database } } ``` `checkHealth` issues a real query. A check that reports whether a pool *object* exists cannot fail, and a health check that cannot fail is not a health check. **Shutdown is already handled.** SIGTERM stops accepting connections, waits for in-flight requests, drains the queue worker, then closes every brick's resources. Give your platform a grace period longer than your slowest request — 30 seconds is a reasonable default — or it will `SIGKILL` mid-drain and undo the point. ## Workers The queue worker runs in your app process in development and not in production, so scale them separately: ```dockerfile # Same image, different command. CMD ["bunx", "oven", "worker", "--concurrency", "20"] ``` That one needs `@theoven/cli` in the image, since it imports your app module rather than the bundle. `oven worker --once` drains and exits, which is what a cron container wants. ## Platforms **Fly.io** — `fly launch` detects the Dockerfile. Set secrets with `fly secrets set`, and put `[[services]]` health checks on `/health`. **Railway / Render** — point them at the Dockerfile. Both send SIGTERM and wait, which is what graceful shutdown needs. **Anywhere with a container runtime** works. There is no serverless adapter: Oven assumes a long-lived process with a real filesystem, brick lifecycles and a queue worker, and pretending otherwise would mean a second set of semantics for the same API. ## Before you call it done - `oven doctor` passes - `NODE_ENV=production` is set — several safety checks are keyed on it - `AUTH_SECRET` comes from a secret store, not the image - Migrations run as a release step, not at boot from every replica at once - Health check points at a route that actually queries something - Shutdown grace period exceeds your slowest request - A volume is mounted for any SQLite file --- ## Testing Source: https://theoven.app/docs/guides/testing/ > Test an Oven app the way a client uses it — no server, no port, no mocks. `app.fetch(request)` runs the whole pipeline — routing, middleware, brick hooks, validation, guards, the handler, serialisation — and returns a `Response`. No port is bound and nothing is mocked. ```ts title="src/app.test.ts" test('it answers', async () => { const response = await app.fetch(new Request('http://localhost/health')) expect(response.status).toBe(200) expect(await response.json()).toEqual({ ok: true }) }) ``` That is the whole idea. Everything below is detail. ## Set the app up once ```ts beforeAll(() => app.ready()) afterAll(() => app.close({ timeout: 1000 })) const send = (path: string, init?: RequestInit) => app.fetch(new Request(`http://localhost${path}`, init)) ``` `ready()` runs every brick's `setup()` — connecting the database, compiling models, mounting auth routes. `close()` releases them, which matters in a watcher: without it each re-run leaks a connection until the database refuses new ones. The scaffold splits `app.ts` (builds and exports) from `index.ts` (calls `listen`) precisely so a test can import the app without binding a port. If your app listens on import, every test file fights for port 3000. ## Test through the pipeline, not around it ```ts // This tests your application. const response = await send('/notes', { method: 'POST', body }) // This tests a function, and skips routing, validation, the guard and serialisation. const result = await handler(fakeContext) ``` The second is where "it worked in the test" comes from. Validation, guards and error mapping are the parts most likely to be wrong, and calling a handler directly is exactly the shape that misses them. ## Bodies ```ts function json(path: string, body: unknown, token?: string) { return send(path, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), }) } ``` Uploads are `FormData`, with no special handling: ```ts const form = new FormData() form.set('title', 'With a file') form.set('file', new File(['bytes'], 'note.txt', { type: 'text/plain' })) const response = await send('/notes', { method: 'POST', body: form }) ``` ## Assert on failures, not only successes The interesting assertions are the refusals: ```ts test('an invalid body is a 422 naming the field', async () => { const response = await json('/users', { name: '' }) expect(response.status).toBe(422) const problem = await response.json() expect(problem.errors[0]).toMatchObject({ location: 'body', path: 'name' }) }) test('a guarded route refuses an anonymous request', async () => { expect((await send('/me')).status).toBe(401) }) ``` Errors are [RFC 9457](https://theoven.app/docs/tutorial/errors/), so `content-type` is `application/problem+json` and the body has `type`, `title`, `status` and `detail`. Assert on the field name, not on the message text — messages get reworded, and a test that breaks when prose changes gets deleted. ## A database per test file `bun:sqlite` in memory is fast enough that a test file can have its own database: ```ts const sqlite = new Database(':memory:') sqlite.exec(MIGRATION) const client = drizzle(sqlite, { schema }) ``` If something needs a client before the app exists — `auth-basic` does — hand the brick your connection with `drizzleSqlite({ client: sqlite })` rather than letting it open its own. Two connections to a file merely waste a handle; two to `:memory:` are two separate databases, and the symptom is `no such table` on a schema you can watch being created. ## Mail, without sending mail ```ts const driver = memoryMail() const app = createApp().use(mail(driver)) await send('/auth/forgot-password', { method: 'POST', body }) expect(driver.sent[0]?.to).toBe('ada@example.com') expect(driver.sent[0]?.text).toContain('/reset?token=') ``` `memoryMail()` collects messages instead of sending them. That is how you test a password-reset flow end to end — including the token in the link — with no mail server. ## Jobs, run on demand ```ts const worker = createWorker( app.service('queue').raw, app.service('queue').jobs, { logger: app.logger }, ) await send('/notes', { method: 'POST', body }) // enqueues await worker.drain() // runs everything currently runnable expect(await app.service('queue').dead(10)).toHaveLength(0) ``` `drain()` runs until nothing more is runnable and returns how many jobs it processed — so a job that enqueues another is handled in the same call. Nothing polls, so a test never waits on a timer. ## Silence the logger ```ts const app = createApp({ logger: silentLogger }) ``` `silentLogger` discards everything. Without it a test run is buried in request logs, and an assertion failure is somewhere in the scroll — but note it also hides the log lines a **failing** test might have explained itself with. When a test fails for reasons you cannot see, take it off and run that one file. ```ts ``` Worth doing for any test that deliberately triggers an error, or your passing suite prints a wall of red stack traces. ## Reaching a brick outside a request ```ts await app.ready() const db = app.service('db') // the same client a request sees const queue = app.service('queue') ``` Seeding a database or asserting on stored rows does not need a request. `app.service()` is typed from what `.use()` contributed, so a name you never registered is a compile error. ## What Oven's own suite does The repository's `examples/kitchen-sink` registers **every** brick in one app and drives it through `fetch`. Unit tests in each package prove each brick works; that one proves they compose, and it has caught three bugs no unit test could have — including guarded routes being documented as public, and two bricks silently opening separate in-memory databases. If you take one habit from it: **verify that a test can fail.** Break the thing on purpose and watch the assertion go red. A test that cannot fail is a comment with a runtime cost, and every suite has a few. --- ## Writing your own brick Source: https://theoven.app/docs/guides/writing-a-brick/ > Add a capability to the request context, fully typed, in about fifty lines. A brick is a normal npm package that exports a factory function. Nothing about the first-party bricks is privileged — `@theoven/db` and `@theoven/mail` use exactly the interface below. ## The smallest brick ```ts export function clock(): Brick<'clock', { now(): Date }> { return { name: 'clock', setup: () => ({ now: () => new Date() }), } } ``` ```ts const app = createApp().use(clock()) app.get('/time', (ctx) => ({ now: ctx.clock.now() })) // ^ typed, because setup's return type flowed through .use() ``` Leave the brick out and `ctx.clock` is a **compile error**, not a crash at 3am. That is the whole point of the system. ## The two contributions This is the distinction that matters most, and the one worth getting right first: | | Runs | Contributes | Cost per request | | --- | --- | --- | --- | | `setup()` | once, at boot | a **shared** service | none — it lives on the prototype | | `request()` | every request | **per-request** state | only what you compute | A database connection is shared. A signed-in user is not. ```ts export function tenant(): Brick<'tenants', TenantIndex, { tenant: Tenant | null }> { return { name: 'tenants', setup: async () => loadTenantIndex(), // shared, once request: (ctx) => ({ tenant: lookup(ctx.header('host')) }), // per request } } app.get('/', (ctx) => ({ known: ctx.tenants.size, // shared, typed current: ctx.tenant?.name, // per-request, typed })) ``` Writing `Brick` on your factory is what gives `onShutdown(value)` its real type and documents the contract where someone reads it. Inference works without it, but the annotation is worth the twelve characters. ## The full interface ```ts interface Brick { name: Name // the context property dependsOn?: string[] // bricks that must be set up first setup(context): Value // the shared service; runs once request?(ctx, route): Request // per-request state onShutdown?(value): void // release pools, workers, connections } ``` ### `setup(context)` The `context` gives you four things: ```ts setup: (context) => { context.resolved.db // values from bricks you declared in dependsOn context.route('GET', '/x', handler) // register your own endpoints context.development // pick safe defaults context.app.contributeOpenApi({ securitySchemes }) context.app.logger } ``` ### `request(ctx, route)` Runs **after routing** — so `ctx.params` and the matched route are available — and **after** the middleware chain has been entered, but **before** validation and the handler. Throwing rejects the request, which is how an auth brick turns a bad token into a `401` without a handler ever seeing it. The full order is on the [middleware page](https://theoven.app/docs/reference/middleware/#the-order-everything-runs-in). The consequence worth knowing: because this runs inside the middleware chain, a plain middleware cannot see what your brick contributed — `ctx.user` does not exist yet at that point. That is deliberate, and it is why route guards are declared on the route rather than written as middleware. It also receives the matched route, including keys core does not interpret: ```ts request: (ctx, route) => { if (route.schema?.auth) requireSignedIn(ctx) } ``` Core carries `auth: 'admin'` and never reads it. Your brick decides what it means — which is what keeps every route-level feature out of core. ## Depending on another brick ```ts export function audit(): Brick<'audit', AuditLog> { return { name: 'audit', dependsOn: ['db'], setup: (context) => createAuditLog(context.resolved.db as Database), } } ``` Declaring the dependency rather than relying on registration order means your brick works however the user wrote their config. A missing dependency or a cycle throws **at boot**, naming the bricks involved. ## Registering endpoints ```ts setup: (context) => { context.route('GET', '/_metrics', () => collectMetrics()) return service } ``` Make it opt-in. A brick that silently adds routes is one that shows up unexplained in `oven routes` — every first-party brick that mounts anything takes a path option and defaults to off, or mounts under a prefix the user chose. ## Cleaning up ```ts onShutdown: async (client) => { await client.close() } ``` Runs during `app.close()`, before the socket is released, with the value `setup()` produced. ## Errors that help Two rules, both learned from things that bite people: ```ts // Name what failed and what to do about it throw new Error( `Could not connect using the "${provider.name}" provider. ` + 'Check DATABASE_URL is reachable from this host.', ) // Fail at boot, not at request time if (options.capabilities?.routes && !options.mount) { throw new Error(`"${name}" declares the "routes" capability but has no mount() method.`) } ``` A configuration mistake found at boot costs one restart. The same mistake found on the first request costs a deploy that looked green. ## Contracts and adapters When your brick wraps something with several implementations — a database, an auth provider, a mail service — split it: 1. **A contract package** defining the smallest interface genuinely common across implementations, with a `raw` escape hatch for the rest. 2. **Adapter packages** implementing it. 3. **Declared capabilities** for anything not universal, checked at boot rather than at request time. The test of a contract is whether a second, structurally different implementation fits without changing it. One adapter proves nothing. And a contract must never invent. If two providers genuinely disagree — one hosts its sign-in page, another mounts routes — expose the difference rather than papering over it with a method one of them has to fake. ## Publishing ```json title="package.json" { "name": "oven-brick-cache", "type": "module", "exports": { ".": "./src/index.ts" }, "peerDependencies": { "@theoven/core": "*" } } ``` `@theoven/core` is a **peer** dependency, not a regular one. Two copies of core in one app means two `Context` classes, and `instanceof` checks that mysteriously fail. Name third-party bricks `oven-brick-*` so they are findable. The `@theoven/*` scope is reserved for first-party ones. ## Write the page too Every first-party brick ships a documentation page in the same commit as its code, following a [fixed shape](https://theoven.app/docs/bricks/): what it installs, what it adds to the context, what endpoints it mounts, what files and tables it creates, what it cannot do. The consistency is the feature — a reader who has seen one page knows where to look on the next, and a coding agent can find "what endpoints does this add" without inferring it from prose. Two rules make those pages useful rather than promotional: - **State what it cannot do.** A page that only lists capabilities is an advertisement. - **List by path every file it writes** into a user's repository, so nothing is discovered in `git status`. ======================================================================================== # Bricks — add one, get a feature ======================================================================================== --- ## Brick catalogue Source: https://theoven.app/docs/bricks/ > Every brick Oven ships — what it does, what it adds, and whether it is built yet. **Add a brick, get a feature.** Each one contributes a capability to the request context, fully typed — and leaving it out makes using it a compile error rather than a crash at 3am. Every page below answers the same questions in the same order: what it installs, what files and tables it creates, what endpoints it adds, and what it cannot do. If a brick has no endpoints, its page says so rather than omitting the section. ## Shipped | Brick | Adds | Endpoints | | --- | --- | --- | | [`openapi`](https://theoven.app/docs/bricks/openapi/) | `ctx.openapi` | `/openapi.json`, `/docs` | | [`db`](https://theoven.app/docs/bricks/db/) | `ctx.db` — the native client | optional health path | | [`db-drizzle`](https://theoven.app/docs/bricks/db-drizzle/) | `ctx.db` — Drizzle, typed from your schema | none | | [`db-mongoose`](https://theoven.app/docs/bricks/db-mongoose/) | `ctx.db` — a Mongoose `Connection` | none | | [`auth`](https://theoven.app/docs/bricks/auth/) | `ctx.user`, `ctx.auth` | none itself | | [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) | email and password auth, on Drizzle | eight, at `/auth/*` | | [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) | the same flows, on Mongoose | eight, at `/auth/*` | | [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/) | `ctx.user` from Clerk, hosted sign-in | **none** | | [`auth-better`](https://theoven.app/docs/bricks/auth-better/) | `ctx.user` from better-auth | **all of its own** | | [`storage`](https://theoven.app/docs/bricks/storage/) | `ctx.storage` — S3, R2, MinIO, or a local directory | none | | [`mail`](https://theoven.app/docs/bricks/mail/) | `ctx.mail` | `/_oven/mail` in development | | [`queue`](https://theoven.app/docs/bricks/queue/) | `ctx.queue` — jobs, retries, cron | `/_oven/queue` in development | | [`cache`](https://theoven.app/docs/bricks/cache/) | `ctx.cache` — tags, stampede protection | none | | [`telemetry`](https://theoven.app/docs/bricks/telemetry/) | OpenTelemetry tracing (middleware) | none | | [`webhooks`](https://theoven.app/docs/bricks/webhooks/) | `ctx.webhook` — verified inbound deliveries | none | | [`ratelimit`](https://theoven.app/docs/bricks/ratelimit/) | shared rate limiting (middleware) | none | | [`vector`](https://theoven.app/docs/bricks/vector/) | `ctx.vector` — embeddings, search, namespaces | none | | [`ai`](https://theoven.app/docs/bricks/ai/) | `ctx.ai` — SSE streaming, caching, token accounting | none | | [`image`](https://theoven.app/docs/bricks/image/) | `ctx.image` — resize, convert, guard uploads | none | | [`storage-bunny`](https://theoven.app/docs/bricks/storage-bunny/) | Bunny.net as a storage driver | none | | [`storage-imagekit`](https://theoven.app/docs/bricks/storage-imagekit/) | ImageKit as a storage driver | none | ## Planned | Brick | Adds | | --- | --- | | `@theoven/db-prisma` | `ctx.db` — a `PrismaClient` | CORS, rate limiting, compression and security headers ship **in core** as [middleware](https://theoven.app/docs/reference/middleware/), because they wrap a request rather than add a capability. A brick is the other thing: it puts something new on the context. ## Writing your own The brick contract is small and public — see [Writing a brick](https://theoven.app/docs/reference/bricks/) for the interface and how types flow. A third-party brick is a normal npm package that exports a factory function; nothing about the first-party ones is privileged. --- ## openapi Source: https://theoven.app/docs/bricks/openapi/ > Serves an OpenAPI 3.1 document and a browsable reference, generated from the schemas your routes already declare. | | | | --- | --- | | **Package** | `@theoven/core` (built in) | | **Adds to context** | `ctx.openapi` | | **Endpoints** | `GET /openapi.json`, `GET /docs` | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install Built into core, so there is nothing to install. ```ts title="src/app.ts" export const app = createApp().use(openapi({ info: { title: 'My API', version: '1.0.0' } })) ``` ## What it does Generates an OpenAPI 3.1 document from the schemas your routes already declare, and serves a browsable reference at `/docs`. You write nothing extra. A documentation page that drifts from the implementation is worse than no page at all, and deriving one from the other is the only way to stop that. ## Endpoints | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `GET` | `/openapi.json` | the generated document | none | | `GET` | `/docs` | the browsable reference | none | Both paths are configurable, and both exclude themselves from the document they describe — documenting the docs endpoint is noise in every generated client. ## Configuration ```ts openapi({ info: { title: 'My API', version: '1.0.0', description: 'Does things' }, servers: [{ url: 'https://api.example' }], path: '/openapi.json', ui: '/docs', // or false to omit the UI securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } }, exclude: ['/internal/metrics'], }) ``` | Option | Default | Purpose | | --- | --- | --- | | `info` | `{ title: 'API', version: '0.0.0' }` | document metadata | | `servers` | — | base URLs for generated clients | | `path` | `/openapi.json` | where the document is served | | `ui` | `/docs` | where the reference is served; `false` to omit | | `securitySchemes` | — | merged with anything auth bricks contribute | | `exclude` | — | paths to leave out | ## What it generates | From your route | Becomes | | --- | --- | | `params` | path parameters, always required | | `query` | query parameters, optional per the schema | | `headers` | header parameters | | `body` | a request body — `multipart/form-data` when it contains a file | | `response` | responses by status code | | `summary`, `description`, `tags` | operation metadata | Two things are added for you: - **`422` on any route that validates input**, with the RFC 9457 problem shape, so a generated client knows the error type it will actually receive. - **Path parameters for routes with no `params` schema.** `/users/:id` puts `{id}` in the URL template, and the spec requires a matching parameter object whether or not you declared one. `HEAD` routes are skipped: they are served from `GET` and carry no separate contract. ## What it creates **Files:** none at runtime. `oven openapi --out openapi.json` writes that file when you ask it to; nothing is written otherwise. **Tables:** none. ## Usage The document comes from schemas you already wrote for validation — declaring them twice is exactly the drift this avoids: ```ts title="src/routes/users/[id].get.ts" export const summary = 'Fetch a user' export const tags = ['users'] export const auth = true export const params = z.object({ id: z.uuid() }) export const response = z.object({ id: z.uuid(), email: z.email(), createdAt: z.iso.datetime(), }) export default async ({ db, params }) => findUser(db, params.id) ``` That route arrives in the document with its path parameter, its response schema, its tag, its summary, and a security requirement — none of it written twice. Registering the brick, with the metadata a generated client needs: ```ts title="src/app.ts" export const app = createApp().use( openapi({ info: { title: 'Acme API', version: '1.2.0', description: 'Everything Acme.' }, servers: [{ url: 'https://api.acme.com', description: 'production' }], tags: [{ name: 'users', description: 'Accounts and profiles.' }], exclude: ['/internal/*'], }), ) ``` ## Capabilities | | | | --- | --- | | Paths, params, query, body, responses from Zod | ✓ | | `summary`, `tags`, `auth` picked up from route exports | ✓ | | Security schemes contributed by auth bricks | ✓ — whichever provider is registered | | RFC 9457 problem responses documented | ✓ | | Non-Zod Standard Schema validators | described permissively, with a startup warning | | Wildcard-mounted routes (e.g. [`auth-better`](https://theoven.app/docs/bricks/auth-better/)) | ✗ — cannot be described | | Webhooks, callbacks, links | ✗ | ## Using it from code ```ts app.get('/spec-summary', (ctx) => { const document = ctx.openapi.document() return { paths: Object.keys(document.paths ?? {}).length } }) ``` Or from the CLI, which is the usual way to feed a client generator: ```bash oven openapi > openapi.json oven openapi --out openapi.json --title "My API" --api-version 1.0.0 ``` ## Limitations **Only Zod schemas can be described.** Zod is the one Standard Schema library that emits JSON Schema today, which is exactly why it is the bundled default. Other validators still work for validation; their operations are documented **permissively** — the route appears with an unconstrained body — and a warning names the vendor at startup so the gap is discoverable. Saying "any value" is honest. Inventing a shape would not be. **A document with no paths warns.** The 3.1 spec permits an empty `paths` object, but strict validators reject a document with no entries anywhere. Rather than fake compliance, the generator says so — a zero-route document usually means the brick was registered before any routes, or `exclude` over-matched. The reference page pulls Scalar from jsDelivr, the same way FastAPI loads Swagger UI. Vendoring a megabyte of JavaScript into core would make every install pay for a page most deployments never open. Pass `ui: false` to omit it entirely. ## How it is verified Generated documents are handed to `@readme/openapi-parser` in the test suite and asserted valid. Structural tests confirm the shape we intended; only a real parser confirms the shape the ecosystem accepts — and it has already caught two bugs that structural tests missed. ## Why 3.1 OpenAPI 3.1's schema dialect **is** JSON Schema 2020-12 — exactly what `z.toJSONSchema` emits. Targeting 3.0 would mean down-converting every schema and losing fidelity on the way. --- ## db Source: https://theoven.app/docs/bricks/db/ > The database contract — connect, health, close, transactions. Your queries stay native. | | | | --- | --- | | **Package** | `@theoven/db` | | **Adds to context** | `ctx.db` — the native client | | **Endpoints** | none, unless you ask for a health path | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/db @theoven/db-drizzle ``` ```ts title="src/app.ts" export const app = createApp().use(db(drizzleSqlite({ url: './data.db', schema }))) ``` ## What it does Connects once at boot, verifies the connection, and closes it cleanly on shutdown. That is the whole job. **`ctx.db` is the native client** — the Drizzle instance, the `PrismaClient` — with nothing of ours wrapped around it: ```ts app.get('/users', (ctx) => ctx.db.select().from(users).where(eq(users.active, true))) ``` A unified `ctx.db.users.find()` would be permanently behind every ORM and worse than all of them. It would also throw away what makes this pleasant with a coding agent: a model knows Drizzle and Prisma cold and would know an invented Oven API not at all. The contract covers what every app needs and every ORM does differently. Queries are not that. ## Endpoints None by default. A health endpoint is opt-in: ```ts db(provider, { healthPath: '/_health/db' }) ``` | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `GET` | whatever you set | `200` healthy, `503` not | none | It answers `503` rather than a `200` carrying `{ healthy: false }`, because a load balancer reads the status line. It is off by default because a brick that silently adds routes is one that shows up unexplained in `oven routes`. ## Configuration | Option | Default | Purpose | | --- | --- | --- | | `healthPath` | `false` | mount a health endpoint | | `checkOnBoot` | `true` | verify the connection at boot rather than on the first query | `checkOnBoot` is on because a misconfigured database that fails at boot costs one restart, while the same mistake found on the first request costs a deploy that looked green. ## Transactions ```ts await transaction(ctx.db, async (tx) => { await tx.insert(orders).values(order) await tx.update(inventory).set({ count: remaining }) }) ``` Portable across providers. You can equally use the ORM's own form — `ctx.db.transaction(...)`, `ctx.db.$transaction(...)` — and should, when you want that ORM's options. A provider without transaction support **refuses** rather than running the work unwrapped. Work that silently escapes its transaction is the failure nobody notices until the data is wrong. [`db-mongoose`](https://theoven.app/docs/bricks/db-mongoose/#transactions) is the one that does this, and its page explains why — Mongoose scopes a transaction to a session, not to a client. ### One transaction per request For endpoints where a half-written request is worse than a failed one, wrap the whole thing: ```ts app.use('/admin', transactional()) ``` `ctx.db` becomes the transaction-scoped client for the duration, so handlers need no change. A request that throws — from a handler, from validation, from another middleware inside it — rolls back everything it wrote. This is middleware rather than a `db()` option so the scope is explicit. Applied globally it holds a transaction open for the length of every request, including the slow ones that call an external API, which is how a connection pool runs out. Put it on the routes that write. Added to an app with no database brick, it does nothing rather than failing — so the line is safe to leave in a template. ### Or one transaction per route A [dependency](https://theoven.app/docs/reference/dependencies/) scopes it to exactly the routes that name it, instead of a path prefix: ```ts title="src/deps.ts" export const tx = dependency('tx', async function* (ctx) { const handle = await begin(ctx.db) try { yield handle await handle.commit() } catch (error) { await handle.rollback() throw error } }) ``` ```ts title="src/routes/orders/index.post.ts" export default route({ deps: { tx }, body: orderSchema }, async (ctx) => { await ctx.deps.tx.insert(orders).values(ctx.body) await ctx.deps.tx.update(stock).set({ count: left }) return { ok: true } }) ``` Which to reach for: | | `transactional()` | a `tx` dependency | | --- | --- | --- | | Scope | a path prefix | the routes that declare it | | Handler changes | none — `ctx.db` is scoped | use `ctx.deps.tx` | | Visible in the route | ✗ | ✓, it is in the signature | | Nested with other setup | ✗ | ✓, dependencies compose | `transactional()` stays the lighter option when a whole prefix writes. The dependency is better when only some routes do, because a reader can see which ones from the route itself. ## What it creates **Files:** none. **Tables:** none. This brick migrates nothing and writes nothing — it manages a connection. What the adapter under it creates is on that adapter's page: [`db-drizzle`](https://theoven.app/docs/bricks/db-drizzle/#what-it-creates), [`db-mongoose`](https://theoven.app/docs/bricks/db-mongoose/#what-it-creates). ## Usage Pick an adapter, register it, and query natively: ```ts title="src/app.ts" export const app = createApp().use( db(drizzleSqlite({ url: './data.db', schema }), { healthPath: '/_health/db', checkOnBoot: true }), ) ``` ```ts title="src/routes/users/index.get.ts" export default async ({ db }) => db.select().from(users).where(eq(users.active, true)) ``` A transaction that rolls back correctly, including across `await`: ```ts await transaction(ctx.db, async (tx) => { await tx.insert(orders).values(order) await tx.update(stock).set({ count: left }).where(eq(stock.sku, order.sku)) }) ``` Or per request, so a handler that throws writes nothing: ```ts app.use(transactional()) ``` `transaction()` throws — naming the provider — on an adapter that declares no transaction support, rather than running your work unwrapped. [`db-mongoose`](https://theoven.app/docs/bricks/db-mongoose/#transactions) is the case that exists. ## Checking health yourself `healthPath` mounts an endpoint. `checkHealth` is the same check, for when you want it inside one of your own: ```ts title="src/routes/health.get.ts" export default async (ctx) => { const database = await checkHealth(ctx.db) ctx.status = database ? 200 : 503 return { status: database ? 'healthy' : 'degraded', database } } ``` It issues a **real query** — `select 1`, or a `ping` on Mongo — rather than asking whether a pool object exists. A health check that cannot fail is not a health check, and one that only inspects a client reports healthy for a connection whose server has gone away. Returns `false` rather than throwing, so a health endpoint answers `503` instead of `500`. A load balancer reads the status line either way, but a `500` looks like a bug in the endpoint rather than a statement about the database. ## Capabilities | | | | --- | --- | | `connect`, `health`, `close` | required of every provider | | `transaction` | **optional** — declared per provider, and refused loudly when absent | | Health endpoint | opt-in via `healthPath` | | Boot-time connection check | on by default | | Query API | ✗ deliberately — `ctx.db` is the native client (D16) | | Migrations | ✗ — your ORM's tooling | | Several databases in one app | ✗ — one connection per app today | **What fails at boot:** an unreachable database when `checkOnBoot` is on, and a provider whose `health` returns false — reported with the provider's name rather than a stack trace from inside a driver. ## How it is verified The contract itself is tested against a **fake provider** — the right tool here, because what is under test is the brick's own behaviour: that `connect` runs once, that a failed boot check throws with the provider named, that `healthPath` answers `503` when unhealthy, that `close` runs on shutdown, and that `transaction()` refuses a provider that declares none. That the contract is genuinely portable is proven elsewhere, by two structurally different adapters passing it: [`db-drizzle`](https://theoven.app/docs/bricks/db-drizzle/) (SQL, client-scoped transactions) and [`db-mongoose`](https://theoven.app/docs/bricks/db-mongoose/) (documents, session-scoped, no portable transaction). One adapter would have proved nothing — a contract shaped around a single ORM fits that ORM and nothing else. ## Writing a provider Four methods: ```ts export function myProvider(options): DatabaseProvider { return { name: 'my-orm:postgres', connect: () => createClient(options), health: async (client) => { await client.query('select 1'); return true }, close: (client) => client.end(), transaction: (client, work) => client.transaction(work), // optional } } ``` `health` should issue a real query rather than reporting whether a pool object exists. A health check that cannot fail is not a health check. ## Limitations - **Switching ORMs still means rewriting queries.** The contract makes lifecycle portable, not query syntax — deliberately (D16). - **One database per app today.** Multiple named connections are not yet supported. --- ## db-drizzle Source: https://theoven.app/docs/bricks/db-drizzle/ > Drizzle ORM over bun:sqlite, Postgres and MySQL — the default database for Oven. | | | | --- | --- | | **Package** | `@theoven/db-drizzle` | | **Adds to context** | `ctx.db` — the Drizzle instance, typed from your schema | | **Endpoints** | none | | **Creates files** | none directly; `oven db generate` writes migrations | | **Creates tables** | only what your own schema declares | | **Status** | shipped | ## Install ```bash bun add @theoven/db @theoven/db-drizzle drizzle-orm ``` ```ts title="src/app.ts" export const app = createApp().use(db(drizzleSqlite({ url: './data.db', schema }))) ``` ## What it does Connects Drizzle to SQLite or Postgres, verifies the connection at boot, and closes it on shutdown. `ctx.db` is the Drizzle instance itself — not a wrapper, not a proxy. Your queries are Drizzle queries, which is also why a coding model already knows how to write them (D16). The brick's job is only the part every application repeats and gets subtly wrong: connecting once, failing loudly at boot rather than on the first request, transactions that actually roll back, and a clean shutdown. ## Endpoints None by default. The [`db`](https://theoven.app/docs/bricks/db/) brick can mount one health endpoint if you ask: ```ts db(drizzleSqlite({ schema }), { healthPath: '/healthz' }) ``` | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `GET` | whatever you pass as `healthPath` | runs `select 1`; `200` healthy, `503` not | none — guard it yourself | Off unless asked for, because a brick that silently adds a route is one that appears unexplained in `oven routes`, and health endpoints are something people want to place and protect themselves. ## Why this is the default `bun:sqlite` and Drizzle are not alternatives — Drizzle **runs on** `bun:sqlite`, so this is Bun's own driver underneath. The reason the default is Drizzle rather than raw SQL is portability. Raw SQL strings are SQLite-dialect-bound, so a raw default means moving to Postgres rewrites every query. With Drizzle, moving is one line: ```ts // before db(drizzleSqlite({ url: './data.db', schema })) // after — every query unchanged db(drizzlePostgres({ url: env.url('DATABASE_URL'), schema })) ``` ## Configuration ### SQLite | Option | Default | Purpose | | --- | --- | --- | | `url` | `./data.db` | file path, or `:memory:` | | `schema` | — | your Drizzle schema, which is what types `ctx.db` | | `logger` | `false` | log every statement | | `tune` | `true` | apply the pragmas below | `tune` sets `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5000` and `foreign_keys = ON`. Without WAL, SQLite serialises readers behind a writer, and a web application meets this as sporadic `SQLITE_BUSY` under exactly the concurrency it was built for. ### Postgres | Option | Default | Purpose | | --- | --- | --- | | `url` | — | connection string; read it from the environment | | `schema` | — | your Drizzle schema | | `max` | `10` | pooled connections | The connection URL is deliberately kept out of the provider's name, because provider names reach logs and a connection string carries a password. ## Usage `ctx.db` is Drizzle. Everything you know about Drizzle applies, and nothing here is Oven-specific: ```ts title="src/routes/users/index.get.ts" export default async ({ db }) => db.select().from(users).orderBy(desc(users.createdAt)).limit(20) ``` ```ts title="src/routes/users/[id].get.ts" export const params = z.object({ id: z.uuid() }) export default async ({ db, params }) => { const [user] = await db.select().from(users).where(eq(users.id, params.id)) if (!user) throw new NotFound(`No user ${params.id}.`) return user } ``` Relational queries, prepared statements, `db.query.*`, partial selects — all of it is Drizzle's, untouched. ### Transactions that roll back Use `transaction()` from `@theoven/db`, **not** Drizzle's own on SQLite — see the warning below: ```ts await transaction(ctx.db, async (tx) => { await tx.insert(orders).values(order) await tx.update(stock).set({ count: remaining }).where(eq(stock.sku, order.sku)) // Anything thrown here rolls both statements back, awaits included. }) ``` To wrap a whole request instead, register the middleware — a request that throws writes nothing: ```ts app.use(transactional()) ``` ## What it creates **Files:** the SQLite database file at `url` (default `./data.db`) when it does not exist — `.gitignore` it. Postgres creates nothing locally. **Tables:** only what your own schema declares. This brick never migrates anything on its own. Generate and apply migrations with `drizzle-kit` — see [Limitations](#limitations) on `oven db`. ## Transactions `bun:sqlite` is synchronous, so Drizzle's `bun-sqlite` transaction commits before an async rejection ever arrives. Work that throws after an `await` is committed anyway. This provider issues `begin`/`commit`/`rollback` itself so the boundaries sit where the awaits are, and serialises transactions — SQLite allows one writer regardless, and two overlapping transactions on one connection would interleave their statements. Use `transaction(ctx.db, ...)` from `@theoven/db` for correct rollback. The behaviour of Drizzle's own method is covered by a test, so the difference stays visible rather than becoming folklore. ## Sharing one connection Something may need a client **before** the app exists — [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) builds its store at construction, so it takes one. Let the brick adopt yours rather than opening a second: ```ts const sqlite = new Database('./data.db') const client = drizzle(sqlite, { schema }) const app = createApp() .use(db(drizzleSqlite({ client: sqlite, schema }))) .use(auth(basicAuth({ db: client, secret }))) ``` Two connections to a file work and waste a handle. Two connections to `:memory:` are **two separate databases** — which shows up as `no such table` on a schema you can watch being created, and is a genuinely confusing hour. An adopted connection is not closed on shutdown: it was not the brick's to open. ## Capabilities | | | | --- | --- | | `connect`, `health`, `close` | ✓ both drivers | | `transaction` | ✓ both — SQLite via explicit `begin`/`commit`/`rollback`, and serialised | | Adopting an existing client | ✓ SQLite (`client`) | | Connection pooling | Postgres only (`max`); SQLite is one connection by design | | Migrations | ✗ — `drizzle-kit`'s job, not this brick's | | MySQL | ✗ — no entry point yet | **What fails at boot:** an unreachable database, or a SQLite path that cannot be opened. With `checkOnBoot` (on by default) that is one failed restart instead of a deploy that looks green and 500s on its first request. ## How it is verified SQLite runs on every `bun test`: booting in memory and from a file, data surviving a restart, a bad path refused at boot, insert/select through `ctx.db`, and that the client really is Drizzle's API rather than a wrapper. Health is checked live, and the mounted endpoint too. Transactions get the most attention, because this is where the provider does something of its own: commit, rollback on failure, and — kept as an explicit test — a demonstration that **Drizzle's own `bun-sqlite` transaction does not roll back an async failure**, so the difference stays a documented fact rather than folklore. The `transactional()` middleware is tested for rollback on a failed request, commit on a successful one, path scoping, and doing nothing when no database is registered. Tuning is asserted by reading the pragmas back, on and off. Postgres runs the same shape — connect, typed `ctx.db`, async rollback, commit, request-scoped transaction, pool release — but only when `POSTGRES_URL` is set. Without it the suite **skips with a printed notice**, and it has not run on the author's machine; CI provides the server. ## Limitations - **MySQL is untested.** Drizzle supports it; this provider has no MySQL entry point yet. - **`oven db generate` is not wired up.** The CLI reports that the command needs this package; migrations are run with `drizzle-kit` directly for now. - **One connection per app.** Read replicas and multiple databases are not yet supported. --- ## db-mongoose Source: https://theoven.app/docs/bricks/db-mongoose/ > MongoDB through Mongoose, on the same database contract as Drizzle. | | | | --- | --- | | **Package** | `@theoven/db-mongoose` | | **Adds to context** | `ctx.db` — a Mongoose `Connection` | | **Endpoints** | none | | **Creates files** | none | | **Creates tables** | only the collections your models declare | | **Status** | shipped | ## Install ```bash bun add @theoven/db @theoven/db-mongoose mongoose ``` ```ts title="src/app.ts" export const app = createApp().use(db(mongooseDb({ url: env.string('MONGO_URL') }))) ``` ```ts title="src/routes/users/index.get.ts" export default async ({ db }) => db.model('User', userSchema).find() ``` ## What it does Connects Mongoose to MongoDB, verifies it at boot with a real `ping`, and closes it on shutdown. `ctx.db` is a Mongoose `Connection` — models come off it, and every Mongoose feature is reachable because nothing is wrapped (D16). It is also the adapter that proves the database contract is a contract; see [What this adapter is for](#what-this-adapter-is-for). ## Endpoints None by default. The [`db`](https://theoven.app/docs/bricks/db/) brick mounts one health endpoint if you ask for it: ```ts db(mongooseDb({ url: env.string('MONGO_URL') }), { healthPath: '/healthz' }) ``` | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `GET` | whatever you pass as `healthPath` | pings the server; `200` healthy, `503` not | none — guard it yourself | ## A Connection, not the global singleton `ctx.db` is a Mongoose [`Connection`](https://mongoosejs.com/docs/api/connection.html), created with `createConnection` — not the `mongoose` default export everyone reaches for first. Models come off it: `ctx.db.model('User', schema)`. The difference matters in three places. Two Oven apps in one process do not fight over global state. A test suite gets its own connection instead of sharing one with whatever ran before it. And `app.close()` genuinely closes *this* app's connection rather than a global one something else may still be using. ## Configuration | Option | Default | Purpose | | --- | --- | --- | | `url` | — | **required**; `mongodb://…` | | `connection` | — | passed to Mongoose unchanged | Everything Mongoose can be told about pooling, timeouts and TLS goes in `connection`. Restating those options here would mean falling behind Mongoose's own within a release. ```ts mongooseDb({ url: env.string('MONGO_URL'), connection: { maxPoolSize: 20, serverSelectionTimeoutMS: 5000 }, }) ``` ## Usage Define schemas once and compile models off `ctx.db`: ```ts title="src/models.ts" export const userSchema = new Schema({ email: { type: String, required: true, unique: true }, name: String, createdAt: { type: Date, default: Date.now }, }) ``` ```ts title="src/routes/users/index.post.ts" export const body = z.object({ email: z.email(), name: z.string() }) export default async ({ db, body }) => db.model('User', userSchema).create(body) ``` ```ts title="src/routes/users/[id].get.ts" export const params = z.object({ id: z.string() }) export default async ({ db, params }) => { const user = await db.model('User', userSchema).findById(params.id).lean() if (!user) throw new NotFound(`No user ${params.id}.`) return user } ``` `db.model()` is idempotent for a given name and connection, so calling it per request is cheap — Mongoose returns the already-compiled model. Aggregations, `populate`, change streams, `bulkWrite`, indexes: all Mongoose's, all available, none of them wrapped. ## What it creates **Files:** none. **Collections:** only what your models declare, created by Mongo on first write. This brick migrates nothing. ## Health checks The [`db`](https://theoven.app/docs/bricks/db/) brick's health path issues a real `ping` to the server. `readyState` would be cheaper and would report healthy for a connection whose server had gone away — a health check that cannot fail is not a health check. ## Transactions This is the one place the adapter does **not** fit the shared contract, and it is worth explaining rather than hiding. ```ts // This throws for Mongoose, naming the provider. await transaction(ctx.db, async (tx) => { /* … */ }) // This is the form to use. await ctx.db.transaction(async (session) => { await Order.create([order], { session }) await Inventory.updateOne(query, update, { session }) }) ``` Every SQL provider scopes a transaction to a *client*: Drizzle hands you a `tx` and every query through it is inside the transaction. Mongoose scopes a transaction to a **session**, attached to each query individually — there is no session-scoped `Connection` to hand you. We could fake one by proxying the connection and every model it returns. It would work for `find` and `save`, and quietly not work for `aggregate`, `bulkWrite`, `watch` and whatever else you reached for. A transaction that silently covers *some* of your writes is worse than none, so the provider declares the truth and `transaction()` refuses. The same applies to [`transactional()`](https://theoven.app/docs/bricks/db/#one-transaction-per-request) — it fails loudly on a Mongo app rather than pretending. A standalone `mongod` has no transactions at all, whatever API you call. This is a second reason not to pretend at the contract level. ## What this adapter is for Beyond running Mongo: it is the **second, structurally different implementation** that tests whether the database contract is real. One adapter proves nothing — a contract shaped around a single ORM will fit that ORM perfectly and nothing else. Three of the four contract methods fit Mongoose unchanged. The fourth did not, and the contract did not bend to accommodate it: `transaction` is optional, and a provider that lacks it says so. That is the outcome the split was designed for. ## Capabilities | | | | --- | --- | | `connect`, `health`, `close` | ✓ | | `transaction` | ✗ — **declared absent**; `transaction()` and `transactional()` refuse loudly | | Pooling, TLS, timeouts | ✓ via `connection`, passed to Mongoose unchanged | | Migrations | ✗ — no `oven db` support; schema lives in your models | **What fails at boot:** a missing `url` is refused at construction, and an unreachable server fails during boot rather than on the first query. ## How it is verified The suite drives a **real MongoDB**: connect, a typed `ctx.db` that is genuinely a Mongoose `Connection`, models compiled off it, the health endpoint answering from a live `ping`, and a clean close on shutdown. The most useful assertions are the negative ones — that `transaction()` **throws** for this provider and names it, and that `transactional()` fails loudly on a Mongo app instead of wrapping nothing. A contract violation that stays silent is the failure mode this whole split exists to avoid. They need `MONGO_URL` and **skip with a printed notice** without it; CI supplies `mongo:8`. They have not run on the author's machine. ## Limitations - **No portable transactions** — see above. - **Mongoose 8, not 9.** Mongoose 9 pulls `bson@7`, which calls a `node:v8` API Bun has not implemented (`isBuildingSnapshot`); importing it fails outright. Still missing as of Bun 1.4, so the peer range stays `^8.0.0` until it lands. - **No migration commands.** `oven db` covers Drizzle and Prisma; Mongo's schema lives in your models, and there is no equivalent worth wrapping. - **Integration tests run in CI, not on your laptop**, against a real `mongo:8` service. Locally they skip unless `MONGO_URL` is set, and say so rather than passing silently. --- ## auth Source: https://theoven.app/docs/bricks/auth/ > The auth contract — identity, route guards, named policies, and the flows every storage brick shares. | | | | --- | --- | | **Package** | `@theoven/auth` | | **Adds to context** | `ctx.user` (`Identity \| null`), `ctx.auth` (the provider) | | **Endpoints** | none itself; a provider may mount its own | | **Creates files** | none | | **Creates tables** | none; storage bricks own their tables | | **Status** | shipped | ## Install Pair it with a provider — `auth-basic` for email and password, or a hosted one. ```bash bun add @theoven/auth @theoven/auth-basic ``` ```ts title="src/app.ts" export const app = createApp() .use(db(drizzleSqlite({ url: './data.db', schema }))) .use(auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') }), { policies })) ``` ## What it does The auth **contract**, not an implementation. It defines what a provider is, enforces route guards, runs your named policies, and puts `ctx.user` on the context typed to whichever provider you registered. It also owns the security-critical half of password auth — argon2id hashing, JWT signing, single-use reset tokens, refresh rotation — so that storage bricks implement seven storage methods and nothing security-sensitive (D26). See [Flows](#flows-for-storage-bricks). Alone it authenticates nobody. Pair it with a provider: | Provider | For | | --- | --- | | [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) | email and password, stored with Drizzle | | [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) | the same, stored with Mongoose | | [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/) | Clerk-hosted sign-in, verified locally | | [`auth-better`](https://theoven.app/docs/bricks/auth-better/) | better-auth mounted inside your app | ## Endpoints **This brick mounts none.** A provider declaring the `routes` capability mounts its own under `prefix` — `auth-basic` and `auth-mongo` add eight endpoints at `/auth/*`, `auth-better` forwards everything under it, and `auth-clerk` adds nothing at all. ## Configuration ```ts auth(provider, { prefix: '/auth', policies }) ``` | Option | Default | | | --- | --- | --- | | `prefix` | `/auth` | where a route-mounting provider is mounted | | `policies` | `{}` | named authorization rules, see [Policies](#policies) | Everything else — secrets, token lifetimes, rate limits — belongs to the provider, on its own page. ## What it creates Nothing. No files, no tables. Storage bricks own their tables and list them on their own pages. ## Guarding a route ```ts app.get('/me', { auth: true }, (ctx) => ctx.user) // 401 when anonymous app.get('/admin', { auth: 'admin' }, (ctx) => ...) // 403 unless the policy passes app.get('/public', (ctx) => ctx.user?.id ?? 'anonymous') // open, user may still be present ``` The guard runs **before** the handler, so a guarded route is unreachable rather than merely inconvenient to reach. A `401` carries `WWW-Authenticate: Bearer`, which is what tells a client *how* to authenticate. ## Identity ```ts interface Identity { id: string email?: string name?: string image?: string raw: Raw // everything the provider returned, typed per brick } ``` Only what every provider genuinely has. Roles, organisations and metadata are **not** normalised: several providers have no such concept, and a field that is permanently `[]` misleads worse than one that is absent (D17). ### `user.raw` — the escape hatch Everything the provider returned, untouched. It is where anything not in the four fields above lives, and it is what a [policy](#policies) reads. What it contains depends entirely on which provider is registered: | Provider | `raw` is | What you would read | | --- | --- | --- | | [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) | the `auth_users` **row** | any column you added — `role`, `plan`, `emailVerifiedAt` | | [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) | the user **document** | any field you added to the collection | | [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/) | the **JWT claims** | `org_id`, `org_role`, `publicMetadata` — whatever your token template includes | | [`auth-better`](https://theoven.app/docs/bricks/auth-better/) | better-auth's user object | anything from its `additionalFields` | It is typed per adapter, so `user.raw.role` autocompletes on `auth-basic` and is a **compile error** on `auth-clerk`, where the equivalent claim is `user.raw.org_role`. That is deliberate: the type is telling you the truth about where your data actually is. On `auth-basic` and `auth-mongo`, `raw` is the **whole stored row** — including `passwordHash`. That is fine for a policy, which runs on the server and returns a boolean. It is not fine to return: ```ts export default async ({ user }) => user.raw // ✗ sends the argon2id hash export default async ({ user }) => ({ id: user.id, email: user.email }) // ✓ ``` A [`response` schema](https://theoven.app/docs/reference/validation/#response-validation) would strip it, since 0.2.0 — but only on routes that declare one, so this is a habit rather than something to rely on. The built-in `/auth/me` returns `id`, `email` and `name` and nothing else, for this reason. ### Where should a role live? `raw` reaches whatever the provider stores. Whether the provider *should* store it is a separate question, and the answer splits cleanly: | | Put it in `raw` | Put it in your own table | | --- | --- | --- | | Scope | one value per user, app-wide | per resource — this board, that organisation | | Example | `role: 'admin'`, `plan: 'pro'` | board membership, team roles, project permissions | | Read cost | free — already on `ctx.user` | a query, per request that needs it | | Portability | tied to the provider | survives changing provider | An app-wide `role` column on `auth_users` is reasonable and cheap. A per-board role is not a property of the user at all, and putting it on the user is how you end up with a `roles` array that has to be rebuilt whenever anything changes. The [Trello project](https://theoven.app/docs/projects/trello/schema/) works through the second case. ## Policies Since roles are not normalised, authorization is a named function you write: ```ts title="src/policies.ts" export const policies = { admin: (user) => user.raw.role === 'admin', owner: (user, ctx) => user.id === ctx.params.userId, } ``` ```ts app.get('/users/:userId/billing', { auth: 'owner' }, handler) app.delete('/posts/:id', { auth: ['admin', 'owner'] }, handler) // all must pass ``` A policy receives the identity and the context, and returns a boolean or a promise of one: ```ts type Policy = (user: Identity, ctx: Context) => boolean | Promise ``` It works on any provider, is greppable, is named in the generated OpenAPI document — and, because it is a plain function, is testable without an app: ```ts title="src/policies.test.ts" test('admin is the role, not the email domain', () => { expect(policies.admin({ id: 'u1', raw: { role: 'admin' } } as never, {} as never)).toBe(true) expect(policies.admin({ id: 'u2', raw: { role: 'member' } } as never, {} as never)).toBe(false) }) ``` That is the argument for a function over a `role: 'admin'` field on the route. A field would have to mean the same thing on every provider, and it does not — so a portable-looking guard would fail silently on the one provider that has no roles. ### The cost of reading `raw` in a policy A policy that reads `user.raw.role` is **tied to the provider that produced it**. Moving from `auth-basic` to Clerk means rewriting that policy, because the role genuinely moved from a database column to a JWT claim. That is honest rather than unfortunate — the alternative is a framework that pretends the two are the same and breaks quietly when they are not. If you expect to switch, read the provider-specific shape in *one* place: ```ts title="src/policies.ts" // The only line that knows where a role is stored. Changing provider changes this, not every rule. const roleOf = (user) => user.raw.role ?? 'member' export const policies = { admin: (user) => roleOf(user) === 'admin', editor: (user) => ['admin', 'editor'].includes(roleOf(user)), } ``` ### Several policies on one route ```ts app.delete('/posts/:id', { auth: ['admin', 'owner'] }, handler) ``` **All** of them must pass — an array is `and`, not `or`. There is no built-in `or` because the combination people reach for is almost always "admin, or the owner", and writing that as one named policy makes the rule readable at the route: ```ts adminOrOwner: (user, ctx) => roleOf(user) === 'admin' || user.id === ctx.params.userId, ``` Referencing a policy that is not registered returns a `500` naming the policies that *are* registered. A typo must never mean "allowed", and failing closed is the only safe reading of a rule that does not exist. ## Social sign-in Google and GitHub are built into the password providers — [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) and [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) — and off until configured. ```ts title="src/app.ts" auth( basicAuth({ db: client, secret: env.string('AUTH_SECRET'), callbackUrl: (provider) => `${env.string('APP_URL')}/auth/oauth/${provider}/callback`, oauth: { google: { provider: google, clientId: env.string('GOOGLE_ID'), clientSecret: env.string('GOOGLE_SECRET') }, github: { provider: github, clientId: env.string('GITHUB_ID'), clientSecret: env.string('GITHUB_SECRET') }, }, }), ) ``` Each configured provider mounts two endpoints and nothing else: | Method | Path | | | --- | --- | --- | | `GET` | `/auth/oauth/:provider` | redirects to the provider | | `GET` | `/auth/oauth/:provider/callback` | completes the sign-in | ### It produces the same session An OAuth sign-in issues the **same** short access JWT and revocable refresh row a password login does (D20). So `auth: true`, your policies, `logout`, sign-out-everywhere and password-change-invalidates-sessions all keep working, and an OAuth session is revocable like any other. Adding social sign-in touches no route you have written. ### Flows are independently optional ```ts basicAuth({ db: client, secret, password: false, callbackUrl, oauth: { google: googleConfig } }) ``` `password: false` mounts no `signup`, `login`, `forgot-password`, `reset-password` or `change-password`. The session endpoints — `refresh`, `logout`, `me` — stay, because a session is a session however it began. An application that signs people in with Google alone does not want a live `/auth/signup` quietly accepting passwords behind it. That is not clutter; it is a way in nobody intended. ### When a provider email matches an existing user The rule, and it is the security-critical part (D33): | | | | --- | --- | | The provider account is already linked | sign in as that user, whatever the email now says | | The email matches a user **and the provider verified it** | link, and sign in | | The email matches but the provider did **not** verify it | **refuse**, with a message saying to sign in and link from settings | | No match | create the user | The third row is the one that exists for a reason. A provider that lets someone claim an address they do not own would otherwise hand them somebody else's account. Google's `email_verified` and GitHub's `verified` flag are both real checks, so both are safe to link on — the rule is what keeps that true when a third provider is added. Identity is keyed on the provider's **subject id**, never the email, so someone who changes their address at Google is still the same user here. ### A provider that returns no email is refused GitHub omits the email unless the `user:email` scope is granted, and a user may have none verified. Since email is the anchor for linking and for recovery, an account without one could never be linked or recovered — so the sign-in fails with a readable message instead (D34). ### Provider tokens are not stored ```ts oauth: { github: { provider: github, clientId, clientSecret, storeTokens: true } } ``` Off by default (D35). Most applications want sign-in, not the provider's API, and tokens nobody reads are a liability rather than a feature. Opt in per provider when you actually need to call them. ### Users created this way have no password They are stored with an **unusable password** — a value nothing can verify — rather than a null one, which is what avoids a breaking migration for every existing installation. `change-password` on such a user answers `409`; they can set a first password through `forgot-password` → `reset-password` like anyone else. Unlinking a provider is refused when it would leave someone with no way to sign in at all. ## Capabilities `identify()` is the only method a provider must implement. Everything else is declared, and a mismatch fails **at boot** rather than at 3am: | Capability | Meaning | | --- | --- | | `routes` | mounts its own endpoints, e.g. `/auth/*` | | `signOut` | can end a session server-side | | `refresh` | can exchange a refresh token | Providers genuinely differ: Clerk cannot sign a user in from a server, and better-auth cannot not-mount routes. Requiring both to pretend produces adapters that throw in production. ## Usage In a route file, the guard is an export and `ctx.user` is narrowed by it: ```ts title="src/routes/me.get.ts" export const auth = true // `user` is `Identity`, not `Identity | null` — the guard above is what proves that. export default async ({ user }) => ({ id: user.id, email: user.email }) ``` ```ts title="src/routes/users/[userId]/billing.get.ts" export const auth = 'owner' export const params = z.object({ userId: z.uuid() }) export default async ({ user, params }) => loadBilling(params.userId) ``` Outside a route — in middleware, where there is no schema to read — narrow explicitly: ```ts app.use(async (ctx, next) => { const user = requireUser(ctx.user) // throws 401 if anonymous ctx.log.info('acting', { userId: user.id }) return next() }) ``` Reading what the provider can do, at runtime: ```ts ctx.auth.name // 'basic', 'clerk', 'better-auth', … ctx.auth.capabilities?.signOut // false on auth-clerk await ctx.auth.signOut?.(ctx) ``` ## Using the pieces directly The eight endpoints are the common case. Everything behind them is exported, for the cases where you need your own endpoint but not your own security. ### The flows ```ts changePassword, login, logout, refresh, requestPasswordReset, resetPassword, signup, } from '@theoven/auth' ``` Each takes `ctx.auth.flows` — the same `FlowConfig` the mounted endpoints use, so password rules, hashing, token lifetimes and single-use reset semantics cannot drift from them: ```ts title="src/routes/register.post.ts" export default async ({ auth, body, db }) => { const { user, tokens } = await signup(auth.flows, body) await db.insert(workspaces).values({ ownerId: user.id, name: body.workspace }) return { user, ...tokens } } ``` | | Returns | Notes | | --- | --- | --- | | `signup(flows, { email, password, name })` | `{ user, tokens }` | `409` on a duplicate email | | `login(flows, { email, password })` | `{ user, tokens }` | identical message and timing for a wrong password and an unknown email | | `refresh(flows, token)` | `{ user, tokens }` | rotates — the old token stops working | | `logout(flows, token)` | `void` | deletes the refresh row; twice is not an error | | `changePassword(flows, userId, { current, next })` | `void` | ends every other session | | `requestPasswordReset(flows, email)` | `void` | succeeds silently for an unknown address, so it cannot be used to find accounts | | `resetPassword(flows, token, password)` | `void` | single use | ### Linking a provider from a settings page Signing in with a provider links it as a side effect. Detaching one is a call you make: ```ts title="src/routes/settings/unlink.post.ts" export const auth = true export const body = z.object({ provider: z.enum(['google', 'github']) }) export default async ({ auth, body, user }) => { await unlinkAccount(auth.flows.store, user.raw, body.provider) return { unlinked: true } } ``` It refuses with a `409` when that provider is the person's **last** way of signing in — removing it would lock them out of their own account, and no confirmation dialog makes that recoverable. Attaching a provider to the account you are already signed in as is not built. Linking happens through signing in, so a password user has to sign out first. Tracked, and the shape above is where it will go. ### Hashing, for seeding and migration ```ts // Importing users from an old system, or seeding a development database. await db.insert(users).values({ ...row, passwordHash: await hashPassword(temporary) }) ``` argon2id with Bun's default cost parameters — not numbers we invented. `verifyPassword` returns `false` for a malformed hash rather than throwing, so a corrupt row fails a login instead of taking down the endpoint. ## Writing a provider ```ts export function myAuth(options): AuthProvider { return { name: 'my-auth', identify: async (ctx) => { const claims = await verify(ctx.token) return claims ? { id: claims.sub, email: claims.email, raw: claims } : null }, } } ``` `identify` must **not** throw for an absent or invalid credential — a public route on the same app still has to work. Return `null`. ## Flows, for storage bricks `@theoven/auth` also ships the security-critical half — argon2id hashing, JWT signing, single-use reset tokens, refresh rotation — behind a seven-method `AuthStore`. A storage brick implements those seven methods and gets every flow. That is why `auth-basic` (Drizzle) and `auth-mongo` (Mongoose) can exist without duplicating password handling: a security fix landing in one and not the other is the bug nobody finds. ## How it is verified The contract is tested with **stub providers** — deliberately, because what is under test is the brick's own logic, not any vendor's: that `auth: true` refuses an anonymous request with a `401` carrying `WWW-Authenticate: Bearer`, that a failing policy gives `403` and not `401`, that an array of policies requires **all** of them, that an unknown policy name fails **closed** with a `500` naming the registered ones, that a public route still works while anonymous, and that a provider declaring `routes` without a `mount()` fails at boot. The flows run here against an **in-memory `AuthStore`** — which doubles as a worked example of how small the contract is — covering signup, login, refresh rotation, logout, password change and reset. The interesting ones are the security properties rather than the happy paths: an unknown email fails with the *identical* message to a wrong password **and in comparable time**, a rotated refresh token stops working, a reset token is single-use, a forgotten-password request for an unknown address succeeds silently rather than confirming absence, and changing a password ends every other session. Crypto is tested separately: argon2id round-trips, the same password hashes differently each time, a malformed hash returns false rather than throwing, tokens are stored only as SHA-256, and access tokens are refused when expired, tampered with, or signed by another secret. Signing without a secret throws rather than signing with nothing. That `AuthStore` is a real contract and not a description of its first implementation is proven elsewhere: the shared `describeAuthStore` suite from `@theoven/auth/testing` is run by [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) against SQLite and [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) against MongoDB. ## Limitations - **No roles or permissions in `Identity`.** Deliberate; use policies. - **Narrowing needs the route's schema.** Inside a route declaring `auth: true` or a policy name, `ctx.user` **is** narrowed to non-null — delete the guard and the code stops compiling. On a route with no guard it stays `Identity | null`, correctly, because an anonymous request can reach it. In a plain middleware, where there is no schema to read, use `requireUser(ctx.user)`. --- ## auth-basic Source: https://theoven.app/docs/bricks/auth-basic/ > Email and password auth that works out of the box — signup, login, refresh, logout, password change and reset. | | | | --- | --- | | **Package** | `@theoven/auth-basic` | | **Adds to context** | `ctx.user`, via `@theoven/auth` | | **Endpoints** | eight, mounted at `/auth/*` | | **Creates files** | none directly; its schema feeds your migrations | | **Creates tables** | `auth_users`, `auth_refresh_tokens`, `auth_reset_tokens` | | **Status** | shipped | ## Install ```bash bun add @theoven/auth @theoven/auth-basic @theoven/db @theoven/db-drizzle ``` ```ts title="src/app.ts" export const app = createApp() .use(db(drizzleSqlite({ url: './data.db', schema }))) .use(auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') }))) ``` That is the Laravel/Rails property: a working signup, login and password reset before you have provisioned anything. SQLite is a file, and reset emails print to your terminal until you configure a provider. ## What it does Email-and-password authentication, stored with Drizzle over SQLite. It mounts eight endpoints at `/auth/*`, hashes with argon2id, and issues a short-lived access JWT plus a revocable refresh token — so `logout`, "sign out everywhere" and "changing a password ends other sessions" are all real rather than client-side gestures (D20). This is the default for `oven create`: signup, login and password reset work before you have provisioned anything. ## Endpoints | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `POST` | `/auth/signup` | create an account | none | | `POST` | `/auth/login` | sign in | none | | `POST` | `/auth/refresh` | new access token from the refresh cookie | cookie | | `POST` | `/auth/logout` | revoke the session | cookie | | `POST` | `/auth/forgot-password` | email a reset link | none | | `POST` | `/auth/reset-password` | redeem a reset token | token | | `POST` | `/auth/change-password` | change while signed in | bearer | | `GET` | `/auth/me` | the current user | bearer | The prefix is configurable: `auth(basicAuth(...), { prefix: '/identity' })`. ## What it creates Three tables, which your migrations must include: ```ts title="src/schema.ts" export { users, refreshTokens, resetTokens } from '@theoven/auth-basic' ``` | Table | Holds | | --- | --- | | `auth_users` | id, email (unique), name, argon2id hash, timestamps | | `auth_refresh_tokens` | hashed refresh tokens, per user, with expiry | | `auth_reset_tokens` | hashed single-use reset tokens, with expiry | Refresh and reset tokens are stored **hashed**, so a leaked database does not hand over working credentials. Both cascade on user delete, so removing a user cannot leave sessions that outlive them. ## Social sign-in Google and GitHub are optional flows on this brick. Two extra steps beyond the configuration on the [`auth` page](https://theoven.app/docs/bricks/auth/#social-sign-in): **1. Add the accounts table to your schema.** It lives on a separate export path, so an application that never uses social sign-in never gets it: ```ts title="src/schema.ts" ins={2} export * from '@theoven/auth-basic/schema' export * from '@theoven/auth-basic/schema/accounts' ``` ```bash oven db generate && oven db migrate ``` **2. Configure a provider.** That is the opt-in — it turns on the store's account methods, and the brick then checks at boot that you did step 1 too. Configuring a provider without the schema fails at **startup**, naming both fixes. The alternative — shipping the table to everyone — means a migration for applications that will never have a linked account, and the line being in *your* schema file is what keeps it from appearing in `git status` unexplained. ### What it creates | Table | Holds | | --- | --- | | `auth_accounts` | id, user, provider, the provider's account id, and optionally its tokens | Unique on `(provider, provider_account_id)`, so the database — not the application — is what prevents two racing callbacks linking the same provider account to two users. Cascades on user delete, so removing a user does not leave credentials pointing at an id that no longer exists. ## How sessions work A **15-minute access JWT** plus a **revocable refresh token**: ``` POST /auth/login → access JWT (body) + refresh token (httpOnly cookie) POST /auth/refresh → new pair; the old refresh token stops working POST /auth/logout → refresh row deleted ``` Identifying a request is a signature check with **no database read**. Revocation lives with the refresh token, which is what makes logout real: delete the row and the access token expires within its window. The refresh token is an httpOnly cookie so a cross-site script cannot read it; the short-lived access token goes in the body, where a client attaches it to `Authorization`. "JWT" and "logout" pull against each other. A stateless token cannot be revoked, so logout would only clear the client's copy — the token keeps working until it expires. This is the design Laravel Sanctum and devise-jwt both arrived at. ## Configuration | Option | Default | Purpose | | --- | --- | --- | | `db` | — | your Drizzle client | | `secret` | — | **required**; signs access tokens | | `accessTtl` | `900` | access-token seconds | | `refreshTtl` | `2592000` | refresh-token seconds (30 days) | | `resetTtl` | `3600` | reset-link seconds | | `minPasswordLength` | `8` | minimum password length | | `sendResetEmail` | — | how reset links are delivered | | `rateLimit` | on | throttles login, signup and reset; `false` to disable | | `refreshCookie` | `oven_refresh` | cookie name | There is no default `secret`, and the brick refuses to construct without one. A framework that invents a signing secret has invented a secret every deployment shares. ## Rate limiting Login, signup and password-reset are the endpoints that actually get attacked, so they are throttled by default. Nothing to add: | Endpoint | Default | Keyed by | | --- | --- | --- | | `POST /auth/login` | 10 per 15 min | IP **and** email | | `POST /auth/signup` | 5 per 15 min | IP | | `POST /auth/forgot-password` | 3 per 15 min | IP **and** email | Over the limit, the request gets `429` with a `Retry-After` header — as [problem+json](https://theoven.app/docs/tutorial/errors/), like every other Oven error. Login and reset count against **both** the caller's IP and the email in the body. Either key alone leaves a hole: limiting by IP does nothing against a distributed attempt on one account, and limiting by email lets a single host spray the whole user table. ```ts basicAuth({ db: client, secret: env.string('AUTH_SECRET'), rateLimit: { login: 20, signup: 10, forgotPassword: 5, window: 5 * 60 * 1000 }, }) ``` They are per process. Behind a load balancer with four instances the effective limit is four times what you configured, and a deploy resets them. That is enough to blunt credential stuffing and reset-email spam, which is what these endpoints face — it is not a precise quota. If you need one, set `rateLimit: false` and put a shared-store limiter in front of `/auth/*`. ## Sending reset emails ```ts basicAuth({ db: client, secret: env.string('AUTH_SECRET'), sendResetEmail: async (to, token) => { await mailer.send({ to, subject: 'Reset your password', text: `Open ${env.string('APP_URL')}/reset?token=${token}`, }) }, }) ``` Omit it and reset tokens are simply not delivered — the flow still works end to end in tests and in development, where you can read the token from the store. ## Security decisions These are the ones worth knowing about, because they shape what the endpoints return: - **Login does not reveal which emails exist.** A wrong password and an unknown address get an identical body, an identical status, and comparable timing — an unknown email still pays an argon2 verification against a decoy hash. - **Forgot-password always succeeds.** Reporting "no such account" would make it an enumeration endpoint. The person who owns the address finds out by email. - **Refresh tokens rotate.** Using one invalidates it. A refresh token that survives its own use is one a thief keeps using alongside the real client. - **Reset tokens are single-use and expiring**, and redeeming one signs every session out. - **Changing a password signs every other session out.** Someone changing a password usually believes an account is compromised. Each of these has a test, including the enumeration and token-reuse cases. ## Usage The endpoints are mounted for you. Your own routes just read `ctx.user`: ```ts title="src/routes/me.get.ts" export const auth = true export default async ({ user }) => ({ id: user.id, email: user.email, name: user.name }) ``` [`user.raw`](https://theoven.app/docs/bricks/auth/#userraw--the-escape-hatch) is the stored row, so any column you added to `auth_users` is reachable — and is what a [policy](https://theoven.app/docs/bricks/auth/#policies) reads: ```ts title="src/app.ts" auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') }), { policies: { admin: (user) => user.raw.role === 'admin' }, }) ``` ### A client, end to end ```ts const { tokens } = await fetch('/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }).then((r) => r.json()) // The access token goes in the header; the refresh token was set as an httpOnly cookie // and is never visible to your JavaScript, which is the point. await fetch('/me', { headers: { authorization: `Bearer ${tokens.accessToken}` } }) // When it expires — the cookie is sent automatically. await fetch('/auth/refresh', { method: 'POST' }) ``` ### Calling the flows from your own endpoint When signup needs to do more than create a user: ```ts title="src/routes/register.post.ts" export const body = z.object({ email: z.email(), password: z.string().min(8), name: z.string(), company: z.string(), }) export default async ({ body, auth, db }) => { const { user, tokens } = await signup(auth.flows, body) await db.insert(companies).values({ name: body.company, ownerId: user.id }) return { user, ...tokens } } ``` `ctx.auth.flows` is the same `FlowConfig` the mounted endpoints use, so password rules, hashing and token lifetimes cannot drift from them. ## Capabilities | | | | --- | --- | | `identify` | ✓ — from the access JWT, no database read on the hot path | | `routes` | ✓ — the eight endpoints above | | `signOut` | ✓ — deletes the refresh row, so logout is real | | `refresh` | ✓ — rotates the refresh token | | Email verification | ✗ — the column exists and is never set | | OAuth / social login | ✗ — use [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/) or [`auth-better`](https://theoven.app/docs/bricks/auth-better/) | | Rate limiting shared across instances | ✗ — per process | **What fails at boot:** a missing `secret`, and a missing `db`. A missing `sendResetEmail` only warns, so reset works in development before mail is configured. ## How it is verified Against a **real SQLite database**, through a real app, over HTTP — not against the store directly. It runs the shared `describeAuthStore` conformance suite from `@theoven/auth/testing`, the same one [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) runs, plus the full flow: signup, login, refresh, logout, change password, forgot, reset. The assertions that matter are the negative ones. A wrong password and an unknown email fail with the *same* message and in comparable time, so the endpoint cannot be used to discover who has an account. A rotated refresh token stops working. A reset token cannot be redeemed twice. Changing a password ends every other session. The password is never stored — asserted by reading the row back and checking the plaintext appears nowhere in it. A test also asserts this brick's endpoint list matches `auth-mongo`'s exactly, which is what keeps "same brick, different storage" true as both change. ## Where the code lives Almost none of this package is in this package. The flows, the eight endpoints, the cookie handling, the rate limits and every piece of cryptography live in `@theoven/auth`, shared with [`auth-mongo`](https://theoven.app/docs/bricks/auth-mongo/) and any other storage brick. What is here is `schema.ts` and `store.ts` — the Drizzle half, and nothing security-critical. That split is the reason `auth-mongo` is a hundred lines rather than a fork. Two auth bricks with their own copies of the token logic is a fix landing in one and not the other, which is the bug nobody finds. ## Limitations - **SQLite schema only.** The tables are declared with `drizzle-orm/sqlite-core`. Postgres works as a database, but you would restate the schema with `pg-core` today. - **No email verification.** `auth_users.email_verified_at` exists and is never set. - **Rate limiting is per process**, not shared across instances — see the caveat [above](#rate-limiting). - **No OAuth.** Use [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/) or [`auth-better`](https://theoven.app/docs/bricks/auth-better/) — both ship, and both satisfy the same contract, so `auth: true` and your policies keep working. --- ## auth-mongo Source: https://theoven.app/docs/bricks/auth-mongo/ > The auth-basic flows, stored in MongoDB with Mongoose. | | | | --- | --- | | **Package** | `@theoven/auth-mongo` | | **Adds to context** | `ctx.user`, `ctx.auth` — it is a provider for [`auth`](https://theoven.app/docs/bricks/auth/) | | **Endpoints** | eight, at `/auth/*` | | **Creates files** | none | | **Creates collections** | `auth_users`, `auth_refresh_tokens`, `auth_reset_tokens` | | **Status** | shipped | ## Install ```bash bun add @theoven/auth @theoven/auth-mongo mongoose ``` ```ts title="src/app.ts" const connection = await createConnection(env.string('MONGO_URL')).asPromise() export const app = createApp() .use(db(mongooseDb({ url: env.string('MONGO_URL') }))) .use(auth(mongoAuth({ connection, secret: env.string('AUTH_SECRET') }))) ``` ## What it does Email-and-password authentication with users, sessions and password resets stored in MongoDB. It mounts the whole flow at `/auth/*`, hashes passwords with argon2id, issues a short-lived access JWT plus a revocable refresh token, and identifies every request from that JWT. It is [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) with Mongoose underneath — literally, not approximately. See below. ## Endpoints | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `POST` | `/auth/signup` | create an account | none | | `POST` | `/auth/login` | sign in | none | | `POST` | `/auth/refresh` | new access token from the refresh cookie | cookie | | `POST` | `/auth/logout` | revoke the session | cookie | | `POST` | `/auth/forgot-password` | email a reset link | none | | `POST` | `/auth/reset-password` | redeem a reset token | token | | `POST` | `/auth/change-password` | change while signed in | bearer | | `GET` | `/auth/me` | the current user | bearer | The prefix is configurable: `auth(mongoAuth(...), { prefix: '/identity' })`. A test asserts this list matches `auth-basic`'s exactly — that is what "different storage, same brick" has to mean to be worth saying. ## It is `auth-basic` with different storage That is the whole description, and it is meant literally. The same eight endpoints, the same tokens, the same cookie, the same rate limits, the same refusals — a test asserts the endpoint list matches `auth-basic`'s exactly. So everything on the [`auth-basic` page](https://theoven.app/docs/bricks/auth-basic/) applies here: the [endpoints](https://theoven.app/docs/bricks/auth-basic/#endpoints), the [configuration](https://theoven.app/docs/bricks/auth-basic/#configuration), the [rate limits](https://theoven.app/docs/bricks/auth-basic/#rate-limiting), the [security decisions](https://theoven.app/docs/bricks/auth-basic/#security-decisions). Only two options differ: | Option | `auth-basic` | `auth-mongo` | | --- | --- | --- | | storage | `db` — a Drizzle client | `connection` — a Mongoose `Connection` | Two auth bricks that had drifted into behaving *almost* the same would be worse than one. The security-critical half — password hashing, token generation, expiry, the endpoints themselves — lives in `@theoven/auth` and is shared. This package is about a hundred lines of document mapping, and there is nowhere for a fix to land in one brick and not the other. ## What it creates Three collections, created on first write: | Collection | Holds | | --- | --- | | `auth_users` | id, email, name, argon2id hash, `emailVerifiedAt`, `createdAt` | | `auth_refresh_tokens` | id, user, **SHA-256 of the token**, expiry | | `auth_reset_tokens` | id, user, **SHA-256 of the token**, expiry, `usedAt` | Neither token is stored in the clear. A leaked database should not hand over working credentials. Indexes are built when the models compile — unique on `auth_users.email` and on both token hashes, plus `auth_refresh_tokens.userId`, which is what "sign out everywhere" deletes by. ### `_id` is your id, not an ObjectId The ids are generated by `@theoven/auth`, so a user id has the same shape whichever storage brick you chose. Letting Mongo assign an ObjectId instead would make the id format depend on that choice — and those ids are visible in JWT subjects, in URLs, and in anything that stored one. ## Configuration ```ts mongoAuth({ connection, secret: env.string('AUTH_SECRET'), accessTtl: 15 * 60, refreshTtl: 30 * 24 * 60 * 60, minPasswordLength: 12, sendResetEmail: (to, token) => mail.send({ to, subject: 'Reset', text: resetUrl(token) }), }) ``` | Option | Default | | | --- | --- | --- | | `connection` | — | **required**; a Mongoose `Connection`, not the global singleton | | `secret` | — | **required**; signs access tokens. No default exists, deliberately | | `accessTtl` | `900` | access-token lifetime, seconds | | `refreshTtl` | `2592000` | refresh-token lifetime, seconds (30 days) | | `resetTtl` | `3600` | reset-link lifetime, seconds | | `minPasswordLength` | `8` | | | `sendResetEmail` | — | without it the reset link is logged, and boot warns | | `rateLimit` | on | `{ login: 10, signup: 5, forgotPassword: 3, window: 900000 }`, or `false` | | `refreshCookie` | `oven_refresh` | name of the httpOnly refresh cookie | Every one of these is the shared option from `@theoven/auth`, so the [`auth-basic` configuration notes](https://theoven.app/docs/bricks/auth-basic/#configuration) apply unchanged. ## Usage The endpoints are mounted for you; your own routes just read `ctx.user`: ```ts title="src/routes/me.get.ts" export const auth = true export default async ({ user }) => ({ id: user.id, email: user.email, name: user.name }) ``` [`user.raw`](https://theoven.app/docs/bricks/auth/#userraw--the-escape-hatch) is the stored user document, so anything you added to the collection is reachable. It is the **whole** document, `passwordHash` included — fine for a policy, not something to return from a handler. ### Calling the flows yourself When you want your own endpoint — a signup that also creates a workspace, say — use the flows rather than reimplementing them: ```ts title="src/routes/register.post.ts" export const body = z.object({ email: z.email(), password: z.string().min(8), name: z.string(), workspace: z.string(), }) export default async ({ body, auth, db }) => { // `signup` returns the identity and the token pair, not one merged object. const { user, tokens } = await signup(auth.flows, body) await db.model('Workspace', workspaceSchema).create({ name: body.workspace, owner: user.id }) return { user, ...tokens } } ``` `ctx.auth.flows` is the shared `FlowConfig` — the same one the mounted endpoints use, so password rules, hashing and token lifetimes stay identical rather than being re-specified here. ## Capabilities | | | | --- | --- | | `identify` | ✓ — from the access JWT | | `routes` | ✓ — the eight endpoints above | | `signOut` | ✓ — `/auth/logout` revokes the refresh row | | `refresh` | ✓ — `/auth/refresh` rotates it | **What fails at boot:** a missing `secret`, and a missing `connection`. A missing `sendResetEmail` warns rather than fails, so reset works in development before mail is configured. ## Housekeeping ```ts await pruneExpiredTokens(connection) ``` Nothing calls this. Expired tokens are already refused on use, so it is housekeeping rather than a security control — run it from a cron job when the collections start to bother you. ## The contract this tests `AuthStore` is seven methods, and until this package it had one implementation. A contract with one implementation is a guess. It fit without changes. The conformance suite lives in `@theoven/auth/testing` and both bricks run the same one: ```ts describeAuthStore('my storage', () => myStore(connection)) ``` If you write a storage brick, run it. It checks the behaviours the flows actually depend on — case-insensitive email lookup, tokens found by hash, `usedAt` persisting, and the one that matters most: that `deleteRefreshTokens({})` deletes **nothing** rather than signing out every user in the database. ## How it is verified Against a **real MongoDB**, not a fake — a fake store would only prove the fake works, and the things that go wrong here are Mongo's own: unique index violations, case-insensitive lookup, `usedAt` persisting. It runs the shared `describeAuthStore` conformance suite from `@theoven/auth/testing` — the same suite `auth-basic` runs — plus the full flow end to end through a real app: signup, login, refresh, logout, password change, reset. The test that matters most is the one asserting `deleteRefreshTokens({})` deletes **nothing**. In Mongo an empty filter matches every document, so the naive implementation of "sign out this user" signs out the entire database. That is a Mongo-specific footgun `auth-basic` cannot have, and it is exactly why the conformance suite exists. They require `MONGO_URL` and **skip with a printed notice** when it is unset. They run in CI against `mongo:8`. They have not been run on the author's machine, and this page would rather say so than imply a coverage it cannot demonstrate. ## Limitations - **Requires a running MongoDB.** There is no in-memory mode; a fake would only prove the fake works. The integration tests are gated on `MONGO_URL` and run in CI against `mongo:8`. - **Mongoose 8, not 9** — see [`db-mongoose`](https://theoven.app/docs/bricks/db-mongoose/#limitations) for why. - **No email verification.** `emailVerifiedAt` exists and is never set, exactly as in `auth-basic`. - **Rate limiting is per process**, as in `auth-basic`. - **No OAuth.** Use [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/) or [`auth-better`](https://theoven.app/docs/bricks/auth-better/) — both ship, and both satisfy the same contract, so `auth: true` and your policies keep working. --- ## auth-clerk Source: https://theoven.app/docs/bricks/auth-clerk/ > Clerk-hosted sign-in, verified on every request. No routes, no sessions to manage. | | | | --- | --- | | **Package** | `@theoven/auth-clerk` | | **Adds to context** | `ctx.user`, `ctx.auth` — it is a provider for [`auth`](https://theoven.app/docs/bricks/auth/) | | **Endpoints** | **none** — Clerk hosts sign-in | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/auth @theoven/auth-clerk ``` ```ts title="src/app.ts" export const app = createApp().use( auth(clerkAuth({ issuer: env.url('CLERK_ISSUER') })), ) ``` ```bash title=".env" CLERK_ISSUER=https://tidy-mole-42.clerk.accounts.dev ``` That is the whole integration. Your frontend signs in with Clerk and sends the session token; this brick verifies it and puts the user on `ctx.user`. ## What it does Verifies Clerk session tokens on every request and puts the result on `ctx.user`. Nothing else — Clerk hosts sign-in, your frontend talks to Clerk directly, and this brick is the backend half that decides whether to trust what arrives. Verification is local: the signing keys are fetched once and cached, so a request costs no round trip to Clerk. ## Endpoints **This brick adds no endpoints.** Clerk hosts sign-in, sign-up, and sign-out; there is nothing for Oven to mount. That is the design, not a gap — see below. ## The opposite end of the contract [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) owns everything: users, passwords, sessions, eight endpoints. This brick owns nothing. It **cannot** sign anyone in or out, because sessions belong to Clerk and the browser talks to Clerk directly. So it declares those capabilities as absent: ```ts capabilities: { routes: false, signOut: false, refresh: false } ``` A `signOut()` that quietly did nothing would be discovered by a user who clicked "sign out" and stayed signed in. Declaring the gap means a route asking for something the provider cannot do fails at **boot**, with a message naming the provider. That is the reason capabilities exist rather than every provider implementing every method. This is also why the brick exists in the first place: a contract that only ever met `auth-basic` would have been shaped around it. `auth-clerk` and `auth-basic` sit at opposite extremes and the same `AuthProvider` interface fits both, unchanged. ## Configuration | Option | Default | Purpose | | --- | --- | --- | | `issuer` | — | **required**; your Clerk instance URL | | `jwksUrl` | `${issuer}/.well-known/jwks.json` | where signing keys are fetched | | `authorizedParties` | — | origins allowed to mint tokens for this backend | | `clockSkew` | `5` | seconds of tolerance on `exp` / `nbf` | | `jwksTtl` | `600000` | how long a fetched key set is trusted, in ms | | `cookie` | `__session` | cookie checked when there is no bearer token | ### Set `authorizedParties` ```ts clerkAuth({ issuer: env.url('CLERK_ISSUER'), authorizedParties: ['https://app.example.com'], }) ``` A valid signature proves *Clerk* minted the token. It does not prove the token was minted for **you**. Without `authorizedParties`, a token issued to a different frontend of the same instance is accepted here. It is optional because Clerk omits `azp` for some token types, and backends that never see a browser would break — but set it if a browser is involved. ## Usage Guard a route and read the user, the same as with any provider: ```ts title="src/routes/me.get.ts" export const auth = true export default async ({ user }) => ({ clerkId: user.id, // the `sub` claim email: user.email, // only if your JWT template includes it — see Limitations }) ``` Anything else in the token is on `raw`, typed as Clerk's claims: ```ts title="src/routes/org/dashboard.get.ts" export const auth = true export default async ({ user }) => { const orgId = user.raw.org_id if (!orgId) throw new Forbidden('This endpoint needs an organization token.') return { orgId } } ``` Roles and organizations are claims, not normalised fields (D17), so anything conditional on them belongs in a named [policy](https://theoven.app/docs/bricks/auth/#policies): ```ts title="src/app.ts" auth(clerkAuth({ issuer: env.url('CLERK_ISSUER') }), { policies: { orgAdmin: (user) => user.raw.org_role === 'admin', }, }) ``` ```ts title="src/routes/org/settings.patch.ts" export const auth = 'orgAdmin' ``` Mirroring Clerk users into your own tables is a webhook you write; this brick does not do it. ## What it creates Nothing. No files, no tables, no migrations. Users live in Clerk. ## Where the token comes from `Authorization: Bearer ` first, because that is what an API client sends. Then the `__session` cookie, which Clerk's browser SDK sets on same-origin requests. Neither present means an anonymous request, not an error — a public route on the same app still has to work. ## How verification works No Clerk SDK. This brick is about two hundred lines on WebCrypto, because a framework brick that depends on a vendor SDK owns that SDK's release cadence and its whole transitive tree. 1. Fetch the instance's JWKS and import the RSA keys, cached for ten minutes. 2. Check the token's header names **RS256** and a `kid` we hold. 3. Verify the signature. 4. Check `iss` matches your issuer exactly, `exp`/`nbf` are within skew, and `azp` is authorized. Step 2 matters more than it looks: the algorithm is a field *in the token*, so trusting it is the classic JWT break — `alg: "none"` means "no signature", and an implementation that honours it accepts anything. Both that and an RS256-key-as-HMAC-secret attempt have tests. ### Key rotation An unknown `kid` usually means Clerk rotated its keys, so the key set is refetched immediately rather than after the TTL — but at most once a minute. Without that limit, a stream of forged `kid`s would be a way to make your server hammer Clerk's key endpoint on an attacker's behalf. ## Capabilities | | | | --- | --- | | `identify` | ✓ — local JWT verification, no call to Clerk per request | | `routes` | ✗ — declared `false`; Clerk hosts sign-in | | `signOut` | ✗ — declared `false`; sessions belong to Clerk | | `refresh` | ✗ — declared `false`; Clerk's SDK refreshes in the browser | **What fails at boot:** a route asking for something the provider declares it cannot do fails at startup, naming the provider and the capability — rather than at 3am, in a handler. ## How it is verified The signature path is tested against **generated RSA key pairs**, so the tokens are really signed and really verified rather than compared to a fixture. The rejections are the point, and each has its own test: a token signed by a different key, `alg: "none"`, an RS256 key offered as an HMAC secret, an unknown `kid`, a missing `kid`, and a malformed token — rejected rather than thrown. Then the claim checks: wrong issuer, expired, not yet valid, no expiry at all, `authorizedParties` enforced when set, and skew tolerated exactly at the edges. Key handling: keys cached across requests, an unknown `kid` triggering at most one refetch a minute, a key server that is down surfacing as an error rather than a silent pass, and a key set with no usable keys treated as an error. End to end through a real app: a bearer token identifies a request, so does the `__session` cookie, a guarded route refuses an anonymous request, and a forged token is refused. No Clerk account is needed for any of this, and none is used. ## Limitations - **No user data beyond the token.** `ctx.user.email` and `.name` are present only if your Clerk JWT template includes them; the default template carries just `sub`. Everything the token does carry is on `ctx.user.raw`. Fetching a full profile would mean a Clerk API call per request. - **No server-side sign-out.** Sign out through Clerk on the frontend. - **No organizations or roles built in.** They arrive as claims on `ctx.user.raw`; turn them into a [policy](https://theoven.app/docs/bricks/auth/#policies) yourself. - **No webhook handling.** If you mirror Clerk users into your own database, that endpoint is yours to write. --- ## auth-better Source: https://theoven.app/docs/bricks/auth-better/ > better-auth mounted in Oven — it brings its own routes, sessions and plugins. | | | | --- | --- | | **Package** | `@theoven/auth-better` | | **Adds to context** | `ctx.user`, `ctx.auth` — it is a provider for [`auth`](https://theoven.app/docs/bricks/auth/) | | **Endpoints** | **all of better-auth's**, mounted at `/auth/*` | | **Creates files** | none | | **Creates tables** | better-auth's own — `user`, `session`, `account`, `verification`, plus whatever your plugins add | | **Status** | shipped | ## Install ```bash bun add @theoven/auth @theoven/auth-better better-auth ``` ```ts title="src/auth.ts" export const instance = betterAuth({ database: new Database('./auth.db'), basePath: '/auth', secret: env.string('BETTER_AUTH_SECRET'), emailAndPassword: { enabled: true }, socialProviders: { github: { clientId: env.string('GITHUB_ID'), clientSecret: env.string('GITHUB_SECRET') }, }, }) ``` ```ts title="src/app.ts" export const app = createApp().use(auth(betterAuthProvider({ instance }))) ``` Run better-auth's own migration once: `bunx @better-auth/cli migrate`. ## What it does Mounts a better-auth instance you built inside your Oven app, and turns its session into `ctx.user`. better-auth owns sign-in, sign-up, OAuth callbacks, verification, and its own tables; this brick routes to it and reads sessions back out. It is the provider that exercises the `routes` capability at its fullest — the opposite end of the contract from [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/), which mounts nothing at all. ## Endpoints | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | any | `/auth` | forwarded to better-auth | better-auth's own | | any | `/auth/*` | forwarded to better-auth | better-auth's own | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` and `OPTIONS` are all forwarded. A wildcard rather than a list, because better-auth's endpoint set depends on which plugins are enabled — see [Everything, on a wildcard](#everything-on-a-wildcard). Which concrete paths exist is better-auth's documentation to give, not this page's: with `emailAndPassword` you get `/auth/sign-up/email` and `/auth/sign-in/email`, with a social provider you also get `/auth/sign-in/social` and `/auth/callback/:provider`, and a plugin adds its own. ## You configure better-auth; this brick mounts it The `instance` is yours. This brick does not wrap `betterAuth()`, take a `database` option, or re-expose its plugins. better-auth's configuration is large, moving, and better documented by better-auth. A wrapper around it would be permanently one release behind and would hide whichever option you needed. So the boundary is drawn where it is honest: you build the instance, Oven routes to it and reads sessions from it. ## Everything, on a wildcard The `routes` capability at its fullest — the opposite end of the contract from [`auth-clerk`](https://theoven.app/docs/bricks/auth-clerk/), which mounts nothing at all. The same `AuthProvider` interface fits both. Every method under the prefix is forwarded: ``` GET|POST|PUT|PATCH|DELETE|OPTIONS /auth GET|POST|PUT|PATCH|DELETE|OPTIONS /auth/* ``` A wildcard rather than a list, because better-auth's endpoint set depends on which plugins are enabled — enumerating them would be wrong the moment you added one. The cost is that `oven routes` shows the wildcard instead of thirty paths, and your OpenAPI document does not describe them. better-auth documents its own endpoints. better-auth defaults to `/api/auth`; Oven mounts at `/auth`. A mismatch is a 404 from *inside* better-auth on every request, which looks exactly like a routing bug in Oven and is not one. The brick checks this at boot and refuses, naming both paths and both ways to fix it. Set `basePath: '/auth'` on the instance, or mount with `auth(provider, { prefix: '/api/auth' })`. ## Sessions `ctx.user` comes from `instance.api.getSession()`, called with the request's headers rather than an extracted token — so whichever scheme you configured, cookie or bearer, keeps working without this brick knowing about it. [`ctx.user.raw`](https://theoven.app/docs/bricks/auth/#userraw--the-escape-hatch) is better-auth's user object, including any fields you added with `additionalFields`. `refresh` is **not** declared: better-auth rotates sessions on its own endpoints, and there is no server-side call for an application to make. Declaring it would offer a method with nothing to do. ## Configuration The brick itself takes two options. Everything else is configured on the better-auth instance. ```ts auth(betterAuthProvider({ instance, skipBasePathCheck: false }), { prefix: '/auth' }) ``` | Option | Default | | | --- | --- | --- | | `instance` | — | **required**; the object returned by `betterAuth({ ... })` | | `skipBasePathCheck` | `false` | skips the boot check that `basePath` matches the mount prefix | The second is deliberately narrow: set it only if you route to better-auth some other way, because what it disables is the check that catches the most common misconfiguration on this page. `prefix` belongs to the [`auth`](https://theoven.app/docs/bricks/auth/) brick and defaults to `/auth`. Whatever you choose must equal better-auth's `basePath`. ## What it creates **Files:** none. **Tables:** better-auth's, not Oven's — `user`, `session`, `account`, `verification`, plus anything your plugins add. Create them with better-auth's own CLI: ```bash bunx @better-auth/cli migrate ``` `oven db` does not know about this schema and will not generate or apply it. ## Usage Guarding a route and reading the user is the same as with any other provider: ```ts title="src/routes/me.get.ts" export const auth = true export default async ({ user }) => ({ id: user.id, email: user.email, // Anything you added with better-auth's `additionalFields` lives here. plan: user.raw.plan, }) ``` Signing out server-side, when better-auth exposes it: ```ts title="src/routes/logout.post.ts" export default async (ctx) => { // `ctx.auth` is the provider itself, so its declared capabilities are readable at runtime. if (!ctx.auth.capabilities?.signOut) throw new BadRequest('This provider cannot sign out.') await ctx.auth.signOut?.(ctx) return { ok: true } } ``` Clients usually call better-auth's own endpoints directly instead — `POST /auth/sign-in/email`, `POST /auth/sign-out` — which is the path better-auth's client library takes. ## Capabilities | | | | --- | --- | | `identify` | ✓ — from `instance.api.getSession()` | | `routes` | ✓ — the whole of better-auth, on a wildcard | | `signOut` | ✓ when the instance exposes `api.signOut`, checked at boot | | `refresh` | ✗ — declared `false`; better-auth rotates sessions on its own endpoints | **What fails at boot:** an `instance` that is missing `handler` or `api.getSession` is rejected immediately with the line you should have written; and a `basePath` that disagrees with the mount prefix is refused, naming both values. ## How it is verified Against a **real better-auth instance on a real SQLite database** — not a double. A double would only prove the double works, and the whole question here is whether better-auth's own endpoints, whatever they happen to be, survive the trip through Oven's router. The suite signs a user up and in through the mounted routes, checks a wrong password is refused, and hits an endpoint the brick never enumerated — the point of the wildcard. Then that the session cookie becomes `ctx.user`, that a guarded route refuses an anonymous request, that a corrupt cookie leaves public routes working anonymously rather than 500ing, that the prefix itself is routed and not only paths beneath it, and that routes outside the prefix are untouched. Boot behaviour too: a `basePath` mismatch fails and names both paths, matching paths boot (trailing slashes included), the check can be waived, a non-instance is refused, and `signOut` is declared only when the instance offers it. The schema those tests run against is generated from better-auth's own table metadata rather than hand-copied, so it cannot drift into a false pass when better-auth adds a column. ## Limitations - **No OpenAPI for the mounted routes.** They arrive as a wildcard, so Oven cannot describe them. - **Migrations are better-auth's.** Run `bunx @better-auth/cli migrate`; `oven db` does not know about its schema. - **Two session systems if you also use `auth-basic`.** Pick one. Nothing stops you registering both, and nothing good comes of it. - **The brick tracks better-auth structurally**, through the `handler` and `getSession` shape rather than its exported types, so it does not pin a version. A breaking change to either would still break it. --- ## mail Source: https://theoven.app/docs/bricks/mail/ > Transactional email — console and memory drivers included, a real provider when you configure one. | | | | --- | --- | | **Package** | `@theoven/mail` | | **Adds to context** | `ctx.mail` | | **Endpoints** | `/_oven/mail` — the preview inbox, development only | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/mail ``` ```ts title="src/app.ts" export const app = createApp().use( mail( env.has('RESEND_API_KEY') ? resendMail({ apiKey: env.string('RESEND_API_KEY'), from: env.string('MAIL_FROM') }) : consoleMail(), ), ) ``` ## What it does Gives you `ctx.mail.send()` and `ctx.mail.sendTemplate()`, with four drivers behind one interface: console, Resend, SES and SMTP. In development it mounts a preview inbox so you can read what your app sent without configuring a provider or sending anything real. If the [`queue`](https://theoven.app/docs/bricks/queue/) brick is registered, sends go through it automatically — with retries and backoff, and without your handler waiting on a third party. ## Endpoints | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `GET` | `/_oven/mail` | the preview inbox | none | | `GET` | `/_oven/mail/:id` | one captured message | none | Mounted **only when `preview` is on**, which by default means development only. Set `preview: '/some/path'` to move it, or `preview: false` to turn it off. ## Sending ```ts await ctx.mail.send({ to: user.email, subject: 'Welcome', text: 'Thanks for signing up.', html: '

Thanks for signing up.

', }) ``` ## Templates A template is a **function**, not a file format: ```ts title="src/emails/welcome.ts" export const welcome = defineTemplate<{ name: string; link: string }>((props) => ({ subject: `Welcome, ${props.name}`, html: `

Hi ${escapeHtml(props.name)}

Confirm your address`, })) ``` A template is a string, and a name is user input. `Hi ${props.name}` with a name of `` puts working script into an email — which renders in some clients, and in a webmail preview pane renders on **your** recipient's screen. `escapeHtml` replaces `& < > " '` with entities. Apply it to every interpolated value in an `html` part. The `text` part needs no escaping, because nothing renders it. ```ts await ctx.mail.sendTemplate({ to: user.email, template: welcome, props: { name: user.name, link }, }) ``` Miss a prop and it is a **compile error**, not an email that says `Welcome, undefined`. That is the whole feature. A template engine here would be a second templating system to learn, a build step to configure, and a class of runtime error that a function signature already prevents. `sendTemplate` is separate from `send` rather than an overload, because a templated message must not carry its own `subject` — a union would let one through and then quietly ignore it. Use `escapeHtml` for anything a user typed. If you want JSX, render it yourself: React Email and `Bun.renderToString` both produce a string, and a template takes whatever you return. ### A text part is derived for you Give only `html` and the text alternative is generated from it — links kept as `text (url)`, scripts and styles dropped. A message with no text part scores worse with spam filters and is unreadable in a client that refuses HTML, so deriving one is strictly better than sending none. Pass your own `text` and it is left alone. ## Attachments ```ts await ctx.mail.send({ to: customer.email, subject: 'Your invoice', text: 'Attached.', attachments: [ { filename: 'invoice.pdf', content: pdfBlob }, { filename: 'logo.png', content: logoBlob, cid: 'logo' }, // ], }) ``` `content` is a `Blob`, bytes, or a string — not base64. An attachment usually starts life as an upload or a generated PDF, both of which are already one of those, and asking every caller to encode is asking them to hold the whole thing in memory as a string. Encoding happens at send time, so an attachment built from `Bun.file()` is not read until the message goes out. ## The preview inbox ``` http://localhost:3000/_oven/mail ``` Every message the app sends is held in memory and rendered there — headers, both bodies, and any attachments. It is how you check what a reset email actually looks like without sending one to yourself. Mounted **in development only**. It serves message bodies, and those contain working password-reset links; an unauthenticated page serving them in production would be a way to take over accounts. Set `preview: '/somewhere'` to move it, `preview: false` to turn it off, or `preview: true` to force it on — which logs a warning outside development, because you should see that decision in your logs. The HTML body renders inside a sandboxed `iframe`, and everything else is escaped. A preview shows whatever an email contains, and some of that comes from users. ## Drivers | Driver | Sends | Use for | | --- | --- | --- | | `consoleMail()` | prints to the terminal | development, the default | | `memoryMail()` | collects in `.sent` | tests | | `resendMail({ apiKey, from })` | Resend's HTTP API | production | | `sesMail({ from, region })` | Amazon SES v2 HTTP API | production on AWS | | `smtpMail({ host, port, user, pass, from })` | SMTP over a socket | anything else | ### SES ```ts mail(sesMail({ from: env.string('MAIL_FROM'), region: env.string('AWS_REGION', 'us-east-1') })) ``` Credentials fall back to `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, which an instance role or a secrets mount already sets. No AWS SDK. Requests are signed with SigV4 in about eighty lines — verified against AWS's own published test vectors, not against itself. Acquiring the SDK's dependency tree in order to send an email is a poor trade. Attachments switch the request from SES's `Simple` content to raw MIME, because `Simple` has nowhere to put a file. Without them, SES builds the MIME itself and does it better than we would. ### SMTP ```ts mail(smtpMail({ host: env.string('SMTP_HOST'), port: env.port('SMTP_PORT', 587), user: env.string('SMTP_USER'), pass: env.string('SMTP_PASS'), from: env.string('MAIL_FROM'), })) ``` Spoken directly over `Bun.connect` — EHLO, STARTTLS, AUTH, MAIL FROM, RCPT TO, DATA. No mailbox management, no pipelining, no connection pool. Prefer an HTTP driver where you have one. SMTP means a long-lived TCP connection, a port that hosting providers block, and errors that arrive as three-digit codes. STARTTLS is negotiated before AUTH, always. If the server does not offer it and you configured a password, the send **fails** rather than handing the credential to the network. For a local mail catcher — Mailpit or MailHog on `127.0.0.1` — pass `allowInsecureAuth`. The console driver is the default so that a freshly created app has a working password-reset flow: the link appears in your terminal, with no provider account and no API key. Laravel and Rails both ship the equivalent. A service that prints reset links to a log instead of emailing them looks perfectly healthy while nobody can reset their password. Booting with `NODE_ENV=production` and the console driver throws. Pass `allowConsoleInProduction` if you genuinely mean it. ## What it creates **Files:** none. **Tables:** none. With the queue brick registered, sends become jobs named `oven:mail:send` in whatever store your queue uses — so a pending message survives a restart when the queue driver is durable. ## Usage ```ts title="src/routes/signup.post.ts" export const body = z.object({ email: z.email(), name: z.string() }) export default async ({ body, mail, db }) => { const user = await db.insert(users).values(body).returning() await mail.send({ to: body.email, subject: 'Welcome to Acme', text: `Thanks for signing up, ${body.name}.`, html: `

Thanks for signing up, ${body.name}.

`, }) return user[0] } ``` Wiring it into password reset, which is the most common reason to want mail at all: ```ts title="src/app.ts" auth( basicAuth({ db: client, secret: env.string('AUTH_SECRET'), sendResetEmail: (to, token) => mailer.send({ to, subject: 'Reset your password', text: `Reset here: ${env.string('APP_URL')}/reset?token=${token}`, }), }), ) ``` Reading what the inbox captured, from your own code: ```ts ctx.mail.inbox?.all() // every captured message; `inbox` is undefined when preview is off ctx.mail.inbox?.find(id) // one of them ctx.mail.inbox?.clear() ctx.mail.driver // 'resend', 'ses', 'smtp' or 'console' ``` ## Testing ```ts const driver = memoryMail() const app = createApp().use(mail(driver)) await request('/signup') expect(driver.sent[0]?.to).toBe('ada@example.com') ``` ## Configuration | Option | Default | Purpose | | --- | --- | --- | | `from` | — | default sender, used when a message sets none | | `allowConsoleInProduction` | `false` | permit the console driver outside development | | `preview` | on in development | mount the inbox; a string moves it, `false` turns it off | | `previewLimit` | `50` | how many messages the inbox keeps | ## Capabilities | | | | --- | --- | | `send`, `sendTemplate` | ✓ all drivers | | Attachments, inline `cid` images | ✓ all drivers | | Automatic plain-text part from HTML | ✓ | | Preview inbox | ✓ development by default | | Retries and backoff | only with [`queue`](https://theoven.app/docs/bricks/queue/) registered | | Bounce / complaint webhooks | ✗ — yours to receive | | SMTP connection pooling | ✗ — one connection per message | | Bulk / marketing sends | ✗ — this is transactional mail | **What fails at boot:** the console driver in production without `allowConsoleInProduction`; a Resend driver with no `apiKey`; an SES driver with no credentials it can resolve. ## What gets logged A send logs the driver and subject. A failure logs the same plus the cause — **never the body**. A failed password-reset email would otherwise put a working reset token into your log aggregator. ## Limitations - **No retries without the queue brick.** On its own, a send is a single attempt. Register [`queue`](https://theoven.app/docs/bricks/queue/) and sends go through it automatically, with retries and backoff. - **No bounce or complaint handling.** Webhooks from Resend and SES are yours to receive. - **No JSX renderer bundled.** Templates return strings; render JSX yourself if you want it. - **SMTP has no connection pool.** One connection per message, which is fine for transactional volume and wrong for bulk. - **The preview inbox is per process and in memory.** It empties on restart and shows nothing from other instances. ## How it is verified SigV4 is checked against AWS's published test vectors rather than against itself — a signature implementation that only agrees with its own tests agrees with nothing. The SMTP driver is tested against a **real SMTP server running in the test process**, so the actual conversation is exercised: EHLO parsing, envelope addresses, dot-stuffing, and the refusal to authenticate over an unencrypted connection. MIME assembly is checked for encoded-word subjects, 76-character base64 wrapping, inline `Content-ID` attachments, and that **bcc never appears in the message** — the whole meaning of blind. --- ## storage Source: https://theoven.app/docs/bricks/storage/ > Object storage on S3, R2, MinIO or Spaces — with a local directory for development, so uploads work before you have a bucket. | | | | --- | --- | | **Package** | `@theoven/storage` | | **Adds to context** | `ctx.storage` | | **Endpoints** | none | | **Creates files** | in development, whatever you upload, under the directory you name | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/storage ``` ```ts title="src/app.ts" export const app = createApp().use( storage( env.has('S3_BUCKET') ? s3Storage({ bucket: env.string('S3_BUCKET') }) : diskStorage({ dir: './storage' }), ), ) ``` ```ts title="src/routes/avatar.post.ts" export default defineRoute( { auth: true, body: z.object({ file: z.file().max(5_000_000) }) }, (ctx) => ctx.storage.upload(`avatars/${ctx.user.id}`, ctx.body.file), ) ``` No SDK. `Bun.S3Client` is in the runtime and signs its own requests, which is why this brick is a few hundred lines rather than a dependency tree. ## What it does Gives you `ctx.storage` — upload, download, list, delete, stat, and presigned URLs — over four drivers: S3 (and anything S3-compatible), local disk, [Bunny](https://theoven.app/docs/bricks/storage-bunny/) and [ImageKit](https://theoven.app/docs/bricks/storage-imagekit/). One interface, so the driver is a config change. Uploads arrive in your handler as web `File` objects because core parses multipart bodies already — there is no `multer` equivalent to install or order correctly. ## Endpoints This brick adds no endpoints. Files are served from your storage provider or through your own routes; nothing is mounted automatically, because a storage brick that silently exposed a public file route would be a security decision made on your behalf. ## What it creates **Files:** with `diskStorage`, the directory at `dir` and everything uploaded into it — add it to `.gitignore`. With the other drivers, nothing locally. **Tables:** none. This brick stores no index of what you uploaded; the bucket is the source of truth. ## Usage ```ts await ctx.storage.upload('reports/q3.pdf', file) // returns { key, size, type, bucket } await ctx.storage.exists('reports/q3.pdf') // boolean await ctx.storage.stat('reports/q3.pdf') // metadata, or null await ctx.storage.delete('reports/q3.pdf') // deleting something absent is fine const { objects, next } = await ctx.storage.list({ prefix: 'reports/', limit: 50 }) const more = await ctx.storage.list({ prefix: 'reports/', after: next }) ``` ### Downloads stream ```ts app.get('/files/[...key]', (ctx) => ctx.storage.download(ctx.params.key)) ``` `download()` returns a **`Blob`** — a lazy handle, not bytes. Nothing is fetched until something reads it, and returning it from a handler streams it to the client without the body ever being held in memory. Both `Bun.file()` and Bun's `S3File` are Blobs, so this is the same line whichever driver is configured. As written it is a public file server. Add `auth: true`, a policy, or a key prefix scoped to the signed-in user — the brick refuses traversing keys, not unauthorised ones. ## Direct uploads from the browser For anything large, do not proxy the bytes through your server: ```ts title="src/routes/uploads.post.ts" export default defineRoute({ auth: true }, (ctx) => ctx.storage.directUpload(`users/${ctx.user.id}/${crypto.randomUUID()}`, { type: 'image/png', expiresIn: 600, }), ) ``` ```ts title="in the browser" const ticket = await (await fetch('/uploads', { method: 'POST' })).json() await fetch(ticket.url, { method: ticket.method, headers: ticket.headers, body: file }) // then tell your API about ticket.key ``` A 2GB file never touches your server: no request timeout to raise, no memory to budget, no bandwidth paid for twice. The ticket is an object rather than a bare URL because **the content type is part of the signature**. Send a different one and S3 answers with a signature mismatch whose message explains nothing — so the header you must send comes back with the URL. | Field | | | --- | --- | | `url` | the presigned URL | | `method` | always `PUT` | | `headers` | what the browser must send; content type when you set one | | `key` | store this — the URL is temporary, the key is not | | `expiresAt` | ISO timestamp | ## Large files There is no separate multipart API. Bun switches to a multipart upload above `partSize` automatically, so a 5MB file and a 5GB file are the same call. Tune it if you need to: ```ts s3Storage({ bucket: 'uploads', partSize: 16 * 1024 * 1024, queueSize: 8 }) ``` An upload from a form arrives as a `File` that Bun already spilled to a temporary file while parsing the request. `ctx.storage.upload()` hands that straight to the driver — nothing calls `arrayBuffer()`, so the bytes go from spill file to bucket without passing through your heap. ## Configuration ### `s3Storage` | Option | Default | Purpose | | --- | --- | --- | | `bucket` | — | **required** | | `accessKeyId`, `secretAccessKey` | AWS env vars | credentials | | `sessionToken` | — | for temporary credentials | | `region` | `us-east-1` | | | `endpoint` | AWS | R2, MinIO, Spaces, B2 | | `virtualHostedStyle` | driver default | MinIO and most self-hosted gateways need `false` | | `partSize` | 5 MiB | bytes per multipart part | | `queueSize` | 5 | parts uploaded in parallel | | `retry` | 3 | retries per part | ```ts // Cloudflare R2 s3Storage({ bucket: 'uploads', endpoint: `https://${accountId}.r2.cloudflarestorage.com` }) // MinIO s3Storage({ bucket: 'uploads', endpoint: 'http://localhost:9000', virtualHostedStyle: false }) ``` ### `diskStorage` | Option | Default | Purpose | | --- | --- | --- | | `dir` | — | **required**; created on first write | | `bucket` | `local` | the name reported on stored objects | ### Several buckets ```ts storage(s3Storage({ bucket: 'uploads' }), { buckets: { avatars: s3Storage({ bucket: 'avatars' }) }, }) await ctx.storage.bucket('avatars').upload(key, file) ``` ## Capabilities `presign` is declared, not assumed: ```ts if (ctx.storage.canPresign) { /* … */ } ``` S3 can hand a browser a URL that uploads straight into the bucket. A local directory cannot — there is no signing authority and no endpoint — so `presignUpload` **throws** on the disk driver rather than returning something that does not work. Check `canPresign`, or serve the object through a route in development. Uploads written to a container's filesystem are lost on the next deploy, and the service looks perfectly healthy while it happens — the failure shows up weeks later as avatars nobody can explain. So it fails at boot outside development. Pass `allowDiskInProduction` if the host genuinely has persistent storage. ## Object keys Keys are usually built from something a user typed, so they are checked before they reach a backend. Traversal (`../`, both separators), absolute paths, empty keys and null bytes are all refused. The disk driver checks a second time after resolving, because the consequence there is a write outside the directory you chose. The key is always what **you** passed. A browser's filename is never used as one — that is how `../../etc/passwd` becomes a file path. ## Limitations - **No content-type or size enforcement on direct uploads.** The type is signed, so a mismatch is rejected; the *size* is not. A presigned `PUT` cannot cap it. If that matters, upload through your server with `z.file().max(n)`, or set a bucket lifecycle policy. - **No image processing.** No resizing, no thumbnails, no transforms. - **The disk driver cannot presign**, and infers content types from the key's extension rather than storing them. - **`list` on the disk driver reads the whole directory tree** before paging. Fine for development; it is not an index. - **No copy or move.** Read and write, or reach through [`ctx.storage.raw`](https://theoven.app/docs/reference/bricks/#raw--the-escape-hatch) — which ties that code to one driver. ## How it is verified Key handling, both drivers, paging, capability refusals and the production guard run everywhere. Presigning is pure computation, so those tests need no server: they assert the URL is signed, carries the expiry, differs between `PUT` and `GET`, and never contains the secret. Real I/O — round trips, a 5MB streamed upload, a 12MB multipart upload, and a direct-upload ticket used exactly as issued — runs against **MinIO in CI**, gated on `S3_ENDPOINT`. Locally those skip and say so. CI fails if they skip. --- ## queue Source: https://theoven.app/docs/bricks/queue/ > Background jobs with retries, backoff, a dead letter and cron — in memory, in Redis, or in the Postgres you already run. | | | | --- | --- | | **Package** | `@theoven/queue` | | **Adds to context** | `ctx.queue` | | **Endpoints** | `/_oven/queue` — the dashboard, development only | | **Creates files** | none | | **Creates tables** | `oven_jobs`, on the Postgres driver only | | **Status** | shipped | ## Install ```bash bun add @theoven/queue ``` ```ts title="src/jobs.ts" export const resizeAvatar = defineJob<{ userId: string; url: string }>({ name: 'resize-avatar', retries: 5, handler: async ({ payload, signal, log }) => { const source = await fetch(payload.url, { signal }) log.info('resized', { userId: payload.userId }) }, }) ``` ```ts title="src/app.ts" export const app = createApp().use(queue(memoryQueue(), { jobs: [resizeAvatar] })) ``` ```ts title="src/routes/avatar.post.ts" export default defineRoute({ auth: true }, async (ctx) => { await ctx.queue.dispatch(resizeAvatar, { userId: ctx.user.id, url }) return { queued: true } }) ``` Miss a field in that payload and it is a **compile error**. The type is declared once on the definition and enforced at both ends — `dispatch` will not take the wrong shape, and the handler receives the right one without a cast. ## What it does Background jobs with typed payloads, retries with exponential backoff, a dead letter, delays, deduplication and cron — over three drivers (in-process, Redis, Postgres) behind one interface. `ctx.queue.dispatch()` enqueues; a worker runs the handler. In development that worker runs inside your app so `bun run dev` is enough to watch a job execute; in production it is `oven worker`, scaling separately from your web processes. ## Endpoints | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | `GET` | `/_oven/queue` | the dashboard — counts, dead letter, upcoming cron | none | Mounted **only when `dashboard` is on**, which by default means development only. `dashboard: '/somewhere'` moves it, `dashboard: false` turns it off. ## Running jobs In development the worker runs **in your app process**, so `bun run dev` and a dispatch is all it takes to watch a job run. In production it does not, because that is where workers should scale separately from web servers: ```bash oven worker # long-running, ctrl-c drains oven worker --concurrency 20 oven worker --once # drain what is queued and exit — for a cron container ``` `oven worker` imports your **app module**, so it gets the same database, the same mail driver and the same job definitions. A worker configured separately from its app is a worker that drifts from it — and the symptom is jobs dead-lettering as "no handler registered" after a deploy. Override the default with `worker: true` or `worker: false`. ## Drivers | Driver | Stores jobs in | Use for | | --- | --- | --- | | `memoryQueue()` | this process | development and tests, the default | | `redisQueue({ url })` | Redis | production, high throughput | | `sqlQueue({ url })` | Postgres | production, when you already run Postgres | Jobs live in one process, so a deploy silently drops every queued email while the service looks perfectly healthy. Booting with `NODE_ENV=production` and the memory driver throws. Pass `allowMemoryInProduction` if losing queued work on restart is genuinely acceptable. `sqlQueue` is worth taking seriously: most applications already run Postgres, and one fewer service to operate usually beats the throughput a dedicated broker adds. It reserves with `FOR UPDATE SKIP LOCKED`, so several workers take different rows rather than queueing behind each other. Reach for Redis when you are enqueueing tens of thousands of jobs a second. ## Retries, backoff and the dead letter ```ts defineJob({ name: 'send-invoice', retries: 5, backoff: 2000, timeout: 60_000, handler }) ``` | | Default | | | --- | --- | --- | | `retries` | `3` | attempts after the first | | `backoff` | `1000` | ms before the first retry, doubling each time | | `timeout` | `30000` | ms one attempt may take | Backoff is exponential so a dependency that is down is not hammered while it recovers. A job that exhausts its retries is **dead-lettered** — kept, with the error that killed it, until someone looks: ```ts const failures = await ctx.queue.dead() await ctx.queue.revive(failures[0].id) // back on the queue, attempts reset ``` Reviving resets the attempt count: someone looked at it and decided it should run, so it gets a full budget rather than dying on its one remaining try. ### Timeouts need a signal ```ts handler: async ({ payload, signal }) => { await fetch(payload.url, { signal }) } ``` A timeout stops the **worker waiting**. It does not stop the work — for that, the handler has to pass `signal` through. The same signal aborts when the worker is shutting down. A reserved job is invisible to other workers for `visibility` ms; if that lapses while the job is still running, another worker picks it up and it runs **twice**. The default is twice the timeout, so raising one is enough. A hand-set pair that is unsafe — including a single job declaring a long `timeout` — fails at boot, naming the job. ## Scheduling ```ts await ctx.queue.dispatch(sendDigest, payload, { delay: 60_000 }) await ctx.queue.dispatch(sendDigest, payload, { runAt: tomorrowAt9 }) ``` ### Deduplication ```ts await ctx.queue.dispatch(rebuildIndex, {}, { key: 'rebuild-index' }) ``` A second job with the same key, while the first is still pending, is dropped — so fifty writes that each want the search index rebuilt rebuild it once. `dispatch` returns `null` when that happens, so you can tell "queued" from "already queued". ### Cron ```ts queue(redisQueue(), { jobs: [cleanUp, sendDigests], cron: { nightly: { schedule: '0 3 * * *', job: cleanUp }, hourly: { schedule: '@hourly', job: sendDigests, payload: { batch: 100 } }, }, }) ``` Standard five-field expressions plus `@daily`, `@hourly` and friends. Lists (`1,15`), ranges (`9-17`), steps (`*/15`) and names (`mon-fri`, `jan`) all work. A bad expression, or one naming a job you did not register, **fails at boot** rather than silently never running. Each firing is deduplicated on the minute, so several instances all ticking enqueue the job once. ## What it creates **Files:** none. **Tables:** with `sqlQueue`, one table — `oven_jobs` by default, configurable with `table` — created on boot if absent, holding queued, running, failed and dead jobs. With `redisQueue`, keys and sorted sets under its prefix. With `memoryQueue`, nothing: jobs live in the process and die with it. ## Usage The full loop — define, register, dispatch, and inspect: ```ts title="src/jobs.ts" export const sendWelcome = defineJob<{ userId: string; email: string }>({ name: 'send-welcome', retries: 5, backoff: 2000, timeout: 10_000, handler: async ({ payload, log }) => { await mailer.send({ to: payload.email, subject: 'Welcome', text: '…' }) log.info('welcome sent', { userId: payload.userId }) }, }) ``` ```ts title="src/routes/signup.post.ts" export default async ({ body, db, queue }) => { const [user] = await db.insert(users).values(body).returning() // The request returns immediately; the email is somebody else's problem now. await queue.dispatch(sendWelcome, { userId: user.id, email: user.email }) return user } ``` The payload type is declared once on the definition and enforced at both ends: a missing or misspelled field is a **compile error**, not a job that dead-letters in production. ### Inspecting the queue from your own routes ```ts title="src/routes/admin/queue.get.ts" export const auth = 'admin' export default async ({ queue }) => ({ driver: queue.driver, stats: await queue.stats(), // counts by state dead: await queue.dead(20), // jobs that exhausted their retries registered: [...queue.jobs.keys()], }) ``` `await queue.revive(id)` puts a dead job back on the queue — the thing you actually want after fixing the bug that killed it. ## The dashboard ``` http://localhost:3000/_oven/queue ``` Counts by state, the dead letter with the error that killed each job, upcoming cron runs and every registered job name. Development only by default; `dashboard: '/somewhere'` moves it, `false` turns it off. ## Configuration | Option | Default | Purpose | | --- | --- | --- | | `jobs` | `[]` | every job this app can run | | `cron` | — | scheduled jobs | | `worker` | dev only | run jobs in this process | | `concurrency` | `5` | jobs at once | | `pollInterval` | `500` | ms between polls when idle | | `visibility` | 2× timeout | ms a reserved job is invisible to other workers | | `retries`, `backoff`, `timeout` | `3`, `1000`, `30000` | defaults for jobs that do not say | | `dashboard` | dev only | mount the dashboard; a string moves it | | `allowMemoryInProduction` | `false` | permit the memory driver outside development | ## Shutdown `app.close()` stops taking new jobs, waits for the ones in flight, and only then releases the driver. A handler gets its `signal` aborted after the grace period so it can stop early rather than being abandoned mid-write. ## Mail goes through it automatically Register both bricks and [`ctx.mail.send()`](https://theoven.app/docs/bricks/mail/) enqueues instead of sending inline — so a slow provider delays an email rather than failing the request that triggered it, and a transient failure is retried rather than lost. Set `queue: false` on the mail brick to keep sends inline. ## Capabilities | | | | --- | --- | | Typed payloads, retries, backoff, timeouts | ✓ all drivers | | Dead letter, `revive()` | ✓ all drivers | | `delay` / `runAt` scheduling | ✓ all drivers | | Deduplication by `key` | ✓ all drivers | | Cron | ✓ all drivers, deduplicated per minute across instances | | Survives a restart | `redisQueue` and `sqlQueue` only | | Several workers without double-running | ✓ — `FOR UPDATE SKIP LOCKED` on SQL, atomic move on Redis | | Job priorities | ✗ | | Job chaining / workflows | ✗ — dispatch the next job from the handler | **What fails at boot:** the memory driver in production without `allowMemoryInProduction`; a cron expression that does not parse; a cron entry naming an unregistered job; and `dispatch` of a job missing from `jobs: [...]` throws with the line that fixes it. ## Limitations - **No priorities.** Jobs run in `runAt` order. A separate queue instance per priority is the workaround. - **No job chaining or batches.** A job that needs to run after another dispatches it itself. - **No result storage.** A handler's return value is discarded; write what you need somewhere. - **The dashboard is read-only** — no retry or delete buttons, and it is per process. - **Cron resolution is one minute**, and schedules are evaluated in the server's local timezone. - **The memory driver is per process**, so a dispatch in a web server is invisible to a separate `oven worker`. That is what the Redis and Postgres drivers are for. ## How it is verified All three drivers run the **same conformance suite**, exported from `@theoven/queue/testing` — because a queue whose semantics change with its backend is a queue you can only trust in the environment you tested it in. It covers the behaviours the worker depends on: that concurrent reserves never hand out the same job, that an expired visibility window reclaims work from a worker that died, that dedupe holds while a job is pending, and that a dead job keeps the error that killed it. Redis and Postgres are gated on `REDIS_URL` / `POSTGRES_URL` and run in CI. CI fails if either suite skips. --- ## cache Source: https://theoven.app/docs/bricks/cache/ > Caching with tag invalidation and stampede protection — in memory, or in Redis. | | | | --- | --- | | **Package** | `@theoven/cache` | | **Adds to context** | `ctx.cache` | | **Endpoints** | none | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/cache ``` ```ts title="src/app.ts" export const app = createApp().use( cache( env.has('REDIS_URL') ? redisCache({ url: env.string('REDIS_URL') }) : memoryCache(), { ttl: 60_000 }, ), ) ``` ## What it does Gives you `ctx.cache` — get, set, delete, tag-invalidate, and `cached()`, which is the one you will actually use. Two drivers ship: an in-process LRU and Redis, behind the same interface, so moving from one to the other is a config change. The two things it does that a `Map` does not: collapsing concurrent misses so a cold popular key does not stampede your database, and invalidating by tag so you do not have to remember every key derived from a thing that changed. ## Endpoints This brick adds no endpoints. ## `cached()` is the whole API ```ts const user = await ctx.cache.cached( `user:${id}`, () => ctx.db.select().from(users).where(eq(users.id, id)), { ttl: 60_000, tags: [`user:${id}`] }, ) ``` Hit, and you get the value. Miss, and the producer runs once and the result is stored. ### Concurrent misses share one call This is the part worth having a library for. A popular key expires, fifty requests miss it in the same instant, and all fifty recompute — a **cache stampede**, which is how a cache turns a slow query into an outage. `cached()` collapses them: the first miss starts the work, the other forty-nine await the same promise. Two instances can still each compute once. Collapsing that needs a distributed lock, which costs a round trip on every miss to save one duplicate call — rarely the right trade. The multiplier is *inside* an instance, and that is what this removes. A producer that throws does not poison the key: the next caller gets a fresh attempt rather than the same rejection forever. ## Invalidating by tag ```ts await ctx.cache.set('user:1:profile', profile, { tags: ['user:1'] }) await ctx.cache.set('user:1:posts', posts, { tags: ['user:1'] }) await ctx.cache.invalidate('user:1') // both gone ``` Tag the *thing* that can change, and invalidate that — rather than remembering every key derived from it, which is the version everyone gets wrong on the third feature. ## The rest ```ts await ctx.cache.get('user:1') await ctx.cache.set('user:1', user, { ttl: 30_000 }) await ctx.cache.delete('user:1') await ctx.cache.clear() ``` ## Usage The common shape — an expensive read, cached and tagged so a write can invalidate it: ```ts title="src/routes/users/[id].get.ts" export const params = z.object({ id: z.uuid() }) export default async ({ cache, db, params }) => cache.cached( `user:${params.id}`, async () => { const [user] = await db.select().from(users).where(eq(users.id, params.id)) if (!user) throw new NotFound(`No user ${params.id}.`) return user }, { ttl: 60_000, tags: [`user:${params.id}`] }, ) ``` ```ts title="src/routes/users/[id].patch.ts" export default async ({ cache, db, params, body }) => { const updated = await db.update(users).set(body).where(eq(users.id, params.id)).returning() // Every key tagged with this user goes, whichever route cached it. await cache.invalidate(`user:${params.id}`) return updated[0] } ``` Note the `throw` inside the producer: it propagates to the caller and **nothing is cached**, so a missing row does not become a cached 404. ### Caching something that is not a database row ```ts const rates = await ctx.cache.cached( 'fx:usd', async () => (await fetch('https://api.example.com/fx/usd')).json(), { ttl: 5 * 60_000 }, ) ``` This is where stampede protection earns its place: a third-party API that rate-limits you will otherwise get one request per concurrent miss the moment the entry expires. ## What it creates **Files:** none. **Tables:** none. With `redisCache`, keys are written under the configured `prefix` (default `oven:cache`), plus one Redis set per tag. Nothing else in your Redis is touched, and `clear()` removes only prefixed keys. ## Drivers | Driver | Stores in | Use for | | --- | --- | --- | | `memoryCache({ max })` | this process | development and tests, the default | | `redisCache({ url })` | Redis | production, and anything shared across instances | `memoryCache` evicts least-recently-used at `max` (default 10 000). An unbounded in-process cache is a memory leak with a friendly name — it grows until the process dies, and the failure looks like a leak somewhere else entirely. Each instance would keep its own copy, so a user sees one value, refreshes, and sees another. That bug reproduces only under load and never on a laptop, which is exactly why it is a boot error. Pass `allowMemoryInProduction` if a per-instance cache is genuinely what you want — for a hot in-process lookup table, it often is. ## Configuration ```ts cache(redisCache({ url: env.string('REDIS_URL'), prefix: 'myapp:' }), { ttl: 60_000, allowMemoryInProduction: false, }) ``` **The brick:** | Option | Default | | | --- | --- | --- | | `ttl` | none | default lifetime in ms when a call does not give one | | `allowMemoryInProduction` | `false` | permit the in-process driver outside development | **`memoryCache(options)`:** | Option | Default | | | --- | --- | --- | | `max` | `10000` | entries kept before least-recently-used eviction | **`redisCache(options)`:** | Option | Default | | | --- | --- | --- | | `url` | `REDIS_URL` | connection string | | `prefix` | `oven:cache` | namespace for every key this cache writes | | `client` | — | an existing `Bun.RedisClient` to adopt instead of connecting | **Per call**, `set()` and `cached()` take: | Option | Default | | | --- | --- | --- | | `ttl` | the brick's `ttl` | lifetime in ms for this entry | | `tags` | none | tags this entry belongs to, for `invalidate()` | ## Capabilities | | | | --- | --- | | `get`, `set`, `delete`, `clear` | ✓ both drivers | | `cached()` with stampede collapsing | ✓ both, within one process | | `invalidate(tag)` | ✓ both | | Shared across instances | Redis only | | LRU eviction | memory only — Redis has its own `maxmemory` policy | | `stale-while-revalidate` | ✗ | | Cross-instance stampede lock | ✗ by choice — see above | **What fails at boot:** `memoryCache` in production without `allowMemoryInProduction`; and a Redis URL that cannot be reached. ## Limitations - **No `stale-while-revalidate`.** An expired entry is a miss; serving stale while refreshing in the background is not implemented. - **No cross-instance stampede lock**, by choice — see above. - **Redis tags are sets of key names.** Invalidating a tag with a very large membership is one `DEL` of many keys, which is fine at thousands and not at millions. - **Values must survive `JSON` round-tripping** on the Redis driver. A `Date` comes back as a string; the memory driver keeps the original reference. ## How it is verified Both drivers run the **same conformance suite** from `@theoven/cache/testing` — expiry, tag invalidation, detaching a re-set key from its old tags, and that clearing empties everything. The stampede test fires twenty concurrent requests at a cold key and asserts the producer ran **once**. The poisoning test was written first and failed: an async function body runs synchronously up to its first `await`, so a producer throwing immediately cleared the in-flight entry before it was ever recorded — and the rejected promise was then served forever. Both are regression tests now. --- ## telemetry Source: https://theoven.app/docs/bricks/telemetry/ > OpenTelemetry tracing — spans named by route, context propagation, and logs you can get back to a trace from. | | | | --- | --- | | **Package** | `@theoven/telemetry` | | **Adds to context** | nothing — it is middleware | | **Endpoints** | none | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/telemetry @opentelemetry/api ``` ```ts title="src/app.ts" export const app = createApp().use(telemetry()) ``` This brick brings neither. You configure them exactly as you would for any OpenTelemetry application, and it uses whatever is globally registered. A framework shipping its own exporter would own that dependency's release cadence forever — and give you two places to configure the same thing. ```ts title="src/telemetry.ts" new NodeSDK({ traceExporter: new OTLPTraceExporter() }).start() ``` Import that before your app and every request is traced. ## What it does Wraps every request in an OpenTelemetry span, named by route pattern, with the standard HTTP attributes on it. It extracts an incoming `traceparent` so a request from another service continues that trace, and exposes `span()` and `traceIds()` for the parts of your own work worth seeing separately. It is **middleware**, not a service — there is no `ctx.telemetry`. Tracing is something that happens to a request, not something a handler reaches for. ## Endpoints This brick adds no endpoints. It never mounts a metrics or trace endpoint; your SDK's exporter sends data out, and nothing needs to be scraped from your app. ## Spans are named by route ``` GET /users/:id ← not GET /users/8f14e45f ``` `http.route` carries the pattern and `url.path` carries the actual path. A backend that sees a million distinct span names cannot aggregate anything, so the pattern is what goes in the name. This works because core exposes `ctx.routePattern` — reconstructing it from the path would mean inventing a rule about which segments are ids, which is wrong on the first slug. ## Only 5xx is an error A `404` or a `422` is the server working correctly: a client asked for something that is not there, or sent something invalid. Marking those as failed spans gives you an error rate made of other people's typos, and nobody looks at a dashboard that is always red. That holds whether the status was *returned* or *thrown* — `throw new NotFound()` is the documented way to answer 404 here, so it is classified by its status like anything else. Only a 5xx records an exception. ## Distributed traces actually connect An incoming `traceparent` is extracted before the span is created, so a request arriving from another service continues that trace rather than starting a disconnected root. Without it, the distributed part of distributed tracing quietly does not work. ## Correlating logs ```ts ctx.log.info('charged card', { amount, ...traceIds() }) ``` A trace you cannot reach from a log line, and a log line you cannot reach from a trace, are two tools instead of one. ## Spans of your own ```ts const rates = await span('fetch-rates', () => fetch(url).then((r) => r.json())) ``` For the parts of a request worth seeing separately — a third-party call, an expensive query. It ends the span whether the work returns or throws. ## Usage Registered once, it applies to everything: ```ts title="src/app.ts" export const app = createApp().use( telemetry({ name: 'checkout-api', version: '2.1.0', ignore: ['/health', '/metrics'], attributes: (ctx) => ({ 'app.tenant': ctx.header('x-tenant-id') ?? 'none' }), }), ) ``` `attributes` runs per request, so anything on the context is available — a tenant, a plan, an API-key id. Keep them low-cardinality: an attribute with a distinct value per request is a dimension your backend cannot group by, and is charged for. Inside a handler, add spans for what is worth separating and stamp trace ids on logs: ```ts title="src/routes/checkout.post.ts" export const auth = true export default async ({ body, db, log }) => { const quote = await span('pricing.quote', () => priceBasket(body.items), { 'basket.size': body.items.length, }) const charge = await span('stripe.charge', () => gateway.charge(quote.total)) log.info('charged card', { amount: quote.total, ...traceIds() }) return db.insert(orders).values({ charge: charge.id }).returning() } ``` `span()` takes an optional third argument of attributes, and ends the span whether the work returns or throws. ## What it creates Nothing — no files, no tables, no endpoints. Spans go wherever your SDK's exporter sends them. ## Capabilities | | | | --- | --- | | Request spans, named by route pattern | ✓ | | `traceparent` extraction — continues an upstream trace | ✓ | | Manual spans via `span()` | ✓ | | Trace/span ids for logs via `traceIds()` | ✓ | | Automatic DB / queue / outbound-`fetch` instrumentation | ✗ — wrap with `span()` | | Metrics and logs signals | ✗ — traces only | | Sampling, exporters, resource attributes | your SDK's, not this brick's | **What fails at boot:** nothing. With no SDK registered, OpenTelemetry's no-op tracer is used and the middleware costs almost nothing — so this brick is safe to leave registered in tests and in development. ## Configuration | Option | Default | | | --- | --- | --- | | `name` | `@theoven/core` | tracer name, as it appears in your backend | | `version` | — | tracer version | | `ignore` | `/health`, `/healthz`, `/_oven/*` | paths never traced; `*` suffix matches a prefix | | `attributes` | — | `(ctx) => Attributes`, for a tenant id or plan | | `useRoutePattern` | `true` | name spans by pattern rather than path | Health checks are ignored by default because a liveness probe every second is the loudest thing in a trace backend and tells you nothing. Setting `ignore` **replaces** that list rather than adding to it. ## Limitations - **Requests only.** Database queries, queue jobs and outbound `fetch` are not instrumented automatically — wrap them with `span()`, or use the relevant OpenTelemetry instrumentation. - **Traces, not metrics or logs.** The other two signals are not wired up. - **No sampling configuration here** — that belongs to your SDK, which is where it already is. ## How it is verified Against a recording tracer registered globally the way a real SDK would be, asserting on the spans actually produced — names, attributes, statuses, exceptions. A test against a mock tracer proves the mock works. The 4xx-versus-5xx test caught a real bug while it was being written: the middleware classified every *thrown* error as a failure regardless of status, which would have put every `throw new NotFound()` into the error rate — the exact mistake the rule exists to prevent, arrived at from the other side. --- ## storage-bunny Source: https://theoven.app/docs/bricks/storage-bunny/ > Bunny.net Storage as a driver for the storage brick — cheap egress through pull zones, signed delivery URLs. | | | | --- | --- | | **Package** | `@theoven/storage-bunny` | | **Adds to context** | `ctx.storage` — it is a driver for [`storage`](https://theoven.app/docs/bricks/storage/) | | **Endpoints** | none | | **Creates files** | whatever you upload, in your storage zone | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/storage @theoven/storage-bunny ``` ```ts title="src/app.ts" export const app = createApp().use( storage( bunnyStorage({ zone: env.string('BUNNY_ZONE'), accessKey: env.string('BUNNY_ACCESS_KEY'), pullZone: 'cdn.example.com', }), ), ) ``` ## What it does Puts Bunny.net Storage behind the storage contract. Everything on `ctx.storage` works exactly as it does on S3 or disk — the driver is the only line that changes. What Bunny adds is cheap egress through its pull zones, which is the reason to pick it. ## Endpoints This brick adds no endpoints. ## Usage **Uploading is `ctx.storage.upload()`** — the same call as every other driver: ```ts title="src/routes/upload.post.ts" export const body = z.object({ file: z.file() }) export default async ({ body, storage }) => { const object = await storage.upload(`uploads/${body.file.name}`, body.file) return { key: object.key, size: object.size } } ``` `upload` takes anything web-shaped — a `File`, `Blob`, `ArrayBuffer`, `ReadableStream` or string — and returns `{ key, bucket, size, type, lastModified }`. ```ts await storage.upload('reports/q3.pdf', pdfBytes, { type: 'application/pdf' }) const blob = storage.download('reports/q3.pdf') // lazy — no request until read const text = await blob.text() await storage.exists('reports/q3.pdf') await storage.stat('reports/q3.pdf') await storage.list({ prefix: 'reports/', limit: 50 }) await storage.delete('reports/q3.pdf') ``` Full detail on each of these is on the [storage](https://theoven.app/docs/bricks/storage/) page; they behave the same here. ## What it creates Nothing in your repository. Objects go to the storage zone you configured. Not the account API key. Find it under the storage zone's **FTP & API Access**. Using the account key gives 401s that look like a wrong password, which is exactly what they are. ## Reads go through the edge With `pullZone` set, `download()` fetches from the CDN. Without it, reads fall back to the storage API — which works, and is slower and costs more. The zone password is **never** sent to the pull zone host; a test asserts that, because leaking a storage credential to a public edge is not a mistake you want to make once. ## Configuration | Option | Default | | | --- | --- | --- | | `zone` | — | **required**; the storage zone name | | `accessKey` | — | **required**; the zone password | | `region` | `storage.bunnycdn.com` | `ny.`, `la.`, `sg.`, `syd.`, `uk.`, `se.`, `br.`, `jh.` | | `pullZone` | — | hostname for public URLs | | `tokenKey` | — | token-authentication key, if enabled on the pull zone | A zone created in one region is not reachable through another's host. ## Signed URLs ```ts ctx.storage.presignDownload('reports/q3.pdf', { expiresIn: 300 }) ``` Available only once both `pullZone` and `tokenKey` are set — `presign` is `undefined` otherwise, so `canPresign` tells you before it matters. Bunny's token authentication protects *delivery*. There is no signed-upload equivalent, so `presignUpload` throws rather than handing back a URL that fails at the edge. This is **not** a limit on uploading. `storage.upload()` works normally; what you cannot do is hand a browser a URL to `PUT` to directly. Send the file to a route and upload it from there, as in [Usage](#usage) above — or use an S3-compatible driver if browser-direct upload is a requirement. ## Capabilities | | | | --- | --- | | `upload`, `download`, `delete`, `exists`, `stat`, `list` | ✓ | | `presignDownload` | ✓, once `pullZone` and `tokenKey` are set — check `storage.canPresign` | | `presignUpload` | ✗ — throws; Bunny has no signed-upload equivalent | | `directUpload` | ✗ — depends on `presignUpload` | | Multiple buckets via `storage.bucket()` | ✗ — one zone per driver; register a second driver instead | ## Limitations - **No browser-direct uploads** — see above; server-side `upload()` is unaffected. - **Uploads are buffered.** Bunny's `PUT` needs a known length, so a stream is collected before sending. S3 streams; this does not. - **`list` is directory-based.** A prefix is split into a directory and a "starts with", so a prefix spanning directories does not work the way it does on S3. - **Paging is client-side.** Bunny returns a whole directory; `limit` and `after` slice it here. ## How it is verified Request shapes, key encoding, pull-zone routing, paging and the URL signing are tested against an injected fetcher — deterministic, and where the bugs are. The token is checked across forty keys to be URL-safe, since base64's `+` and `/` do not survive a query string. That Bunny *accepts* those requests is not verified here: live tests are gated on `BUNNY_ZONE` and skip with a printed notice. --- ## storage-imagekit Source: https://theoven.app/docs/bricks/storage-imagekit/ > ImageKit as a storage driver, plus the transformation URLs that are the reason to use it. | | | | --- | --- | | **Package** | `@theoven/storage-imagekit` | | **Adds to context** | `ctx.storage` — it is a driver for [`storage`](https://theoven.app/docs/bricks/storage/) | | **Endpoints** | none | | **Creates files** | whatever you upload, in your media library | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/storage @theoven/storage-imagekit ``` ```ts title="src/app.ts" const media = imagekitStorage({ privateKey: env.string('IMAGEKIT_PRIVATE_KEY'), urlEndpoint: env.string('IMAGEKIT_URL_ENDPOINT'), }) export const app = createApp().use(storage(media)) ``` ## What it does Puts ImageKit behind the storage contract, so `ctx.storage` works exactly as it does on S3 or disk. On top of that the driver exposes `url()`, which builds ImageKit's transformation URLs — resize, crop, reformat on delivery. ## Endpoints This brick adds no endpoints. ## Usage **Uploading is `ctx.storage.upload()`** — the same call as every other driver: ```ts title="src/routes/avatar.post.ts" export const auth = true export const body = z.object({ file: z.file() }) export default async ({ body, storage, user }) => { const key = `avatars/${user.id}.png` await storage.upload(key, body.file) return { url: media.url(key, { transform: { width: 200, height: 200, format: 'auto' } }) } } ``` Upload the original once, then serve whatever size you need from the URL — that is the pattern ImageKit exists for, and why you do not store thumbnails yourself. ```ts await storage.upload('photos/a.png', bytes, { type: 'image/png' }) const blob = storage.download('photos/a.png') // lazy — no request until read await storage.exists('photos/a.png') await storage.stat('photos/a.png') await storage.list({ prefix: 'photos/', limit: 50 }) await storage.delete('photos/a.png') ``` Full detail on each is on the [storage](https://theoven.app/docs/bricks/storage/) page; they behave the same here. ## What it creates Nothing in your repository. Files go to your ImageKit media library. ## Transformations The reason to use ImageKit rather than plain object storage: ```ts media.url('avatars/1.png', { transform: { width: 200, height: 200, format: 'auto' } }) // https://ik.imagekit.io/demo/avatars/1.png?tr=w-200,h-200,f-auto ``` `format: 'auto'` lets ImageKit serve webp or avif per browser, which is most of the win. Deliberately on the **driver**, not on `ctx.storage`: resizing an image is not something a storage contract should have an opinion about, and inventing one would mean every other driver pretending it could crop. ### Signed URLs ```ts media.url('private/report.png', { transform: { width: 800 }, expiresIn: 300 }) ``` The signature covers the transformation as well as the path — so a client cannot edit `tr=` to request a 10 000px render off a signed URL, which matters on a product billed per transformation. ## Configuration | Option | Default | | | --- | --- | --- | | `privateKey` | — | **required**; never send it to a browser | | `urlEndpoint` | — | **required**, e.g. `https://ik.imagekit.io/your_id` | The storage contract is path-keyed; ImageKit deletes and inspects by `fileId`. So `remove`, `exists` and `stat` each search for the file first — one extra round trip per call, which S3 and Bunny do not pay. It is visible in the tests rather than hidden. Uploads set `useUniqueFileName: false`, because ImageKit otherwise appends a random suffix — and then the key you asked for is not the key you get, which silently breaks every other method in the contract. ## Capabilities | | | | --- | --- | | `upload`, `download`, `delete`, `exists`, `stat`, `list` | ✓ | | `media.url()` with transformations and signing | ✓ | | `presignDownload` / `presignUpload` | ✗ — use `media.url({ expiresIn })` for signed delivery | | `directUpload` | ✗ — depends on `presignUpload` | | Multiple buckets via `storage.bucket()` | ✗ — one media library per driver | ## Limitations - **No browser-direct uploads.** ImageKit's browser upload uses a token/signature triple posted to their endpoint, not a `PUT`-able URL, so `presignUpload` is unavailable rather than misleading. Server-side `upload()` is unaffected — send the file to a route, as in [Usage](#usage). - **An extra lookup on `remove`, `exists` and `stat`** — see above. - **Paging is offset-based**, so `next` is a count rather than a key. - **Uploads are buffered**, since the upload API is multipart. ## How it is verified URL building, transformation syntax and signing are deterministic and fully tested — including that changing a transformation changes the signature. Request shapes and the fileId lookup are tested against an injected fetcher. That ImageKit *accepts* those requests is not verified: there is no account in CI. --- ## webhooks Source: https://theoven.app/docs/bricks/webhooks/ > Inbound webhooks verified before your handler runs — Stripe, GitHub, Slack, Svix, Razorpay and Shopify. | | | | --- | --- | | **Package** | `@theoven/webhooks` | | **Adds to context** | `ctx.webhook` — on verified routes only | | **Endpoints** | none — you write the route | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/webhooks ``` ```ts title="src/routes/webhooks/stripe.post.ts" export default webhook(stripe({ secret: env.string('STRIPE_WEBHOOK_SECRET') }), async (ctx) => { const event = ctx.webhook.payload as { type: string; id: string } return { received: event.id } }) ``` ## What it does Verifies an inbound webhook's signature and refuses the request when it does not check out — so the handler runs only for deliveries that genuinely came from the provider. It is not a brick in the `.use()` sense: there is nothing to register, and `ctx.webhook` exists on the routes that ask for it rather than on every request. The obvious version verifies inside the handler: ```ts export default async (ctx) => { if (!verify(ctx)) throw new Unauthorized() // one early return away from not running // … } ``` That is the same argument as [`auth: true`](https://theoven.app/docs/bricks/auth/#guarding-a-route) running before a handler rather than being a check you remember to write. An unverified delivery should be **unreachable**, not merely inconvenient to act on. ## Endpoints This package adds none. You write the route; it wraps the handler. ## Providers | | Header | What is signed | Encoding | Replay-protected | | --- | --- | --- | --- | --- | | `stripe` | `Stripe-Signature` | `${timestamp}.${body}` | hex | ✓ | | `slack` | `X-Slack-Signature` | `v0:${timestamp}:${body}` | hex | ✓ | | `svix` | `svix-signature` | `${id}.${timestamp}.${body}` | base64 | ✓ | | `github` | `X-Hub-Signature-256` | the body | hex | ✗ | | `razorpay` | `X-Razorpay-Signature` | the body | hex | ✗ | | `shopify` | `X-Shopify-Hmac-Sha256` | the body | **base64** | ✗ | `svix` covers **Clerk, Resend, Brex** and everything else built on Svix — same scheme, same verifier. The "what is signed" column is the reason this package exists. Every one of these is HMAC-SHA256 and every one signs a *different string*; signing the bare body for Slack, or hex instead of base64 for Shopify, produces a check that is wrong in a way that looks right. ## Configuration ```ts stripe({ secret: env.string('STRIPE_WEBHOOK_SECRET'), tolerance: 300 }) ``` | Option | Default | | | --- | --- | --- | | `secret` | — | **required**; the endpoint's signing secret, never an API key | | `tolerance` | `300` | seconds a delivery may be old — the providers that support it | For Svix the secret is the `whsec_…` string as given. The prefix is stripped and the rest is **base64-decoded** to make the key — using it as a plain string is the single most common reason a hand-rolled Svix check fails, and it fails for every delivery while looking correct. ## Usage ### What the handler gets ```ts ctx.webhook.payload // parsed from the bytes the signature covered ctx.webhook.id // the provider's delivery id, where it sends one ctx.webhook.timestamp // seconds, on the providers that sign one ctx.webhook.provider // 'stripe', 'github', … ctx.webhook.raw // the exact bytes ``` ### Handing off to a queue A provider retries when your endpoint is slow, so the work belongs somewhere else: ```ts export default webhook(stripe({ secret }), async (ctx) => { await ctx.queue.dispatch(handleStripe, ctx.webhook.payload, { key: ctx.webhook.id }) return { received: true } }) ``` The dedup `key` is doing real work: providers deliver **at least once** by design, and a duplicate `charge.succeeded` must not fulfil an order twice. Keying the job on the delivery id makes the second one a no-op. ### Noticing when it breaks ```ts webhook(stripe({ secret }), handler, { onRejected: (failure, ctx) => ctx.log.warn('webhook rejected', { ...failure }), }) ``` A webhook that starts failing is silent: the provider retries, gives up, and nobody finds out until something downstream is missing. A rotated secret and a drifted clock both look like this. ## What it creates Nothing. No files, no tables, no endpoints. ## Capabilities | | | | --- | --- | | Signature verification against the raw bytes | ✓ all providers | | Constant-time comparison | ✓ — Node's native `timingSafeEqual` | | Replay rejection | ✓ Stripe, Slack, Svix · ✗ GitHub, Razorpay, Shopify | | Multiple valid signatures during a rotation | ✓ Stripe, Svix | | Idempotency | ✗ — yours, via `ctx.webhook.id` | | A delivery log | ✗ | ## Limitations - **Three providers cannot detect a replay.** GitHub, Razorpay and Shopify sign the body and nothing else, so a captured delivery stays valid forever and no implementation can tell. Check `verifier.replayProtected`, and deduplicate on the delivery id where it matters. - **No delivery log or automatic idempotency.** The queue's dedup keys are the intended answer. - **`payload` is JSON only.** A form-encoded webhook — some Slack endpoints — comes through as `raw`. - **No outbound webhooks.** Sending them, with retries and signing, is a different feature. - **Six providers.** Anything else means writing a `WebhookVerifier`, which is one function. ## How it is verified The HMAC is checked against **GitHub's own published example** — secret, payload and expected signature taken from their documentation rather than produced by this code. An expected value computed by the implementation under test proves only that it agrees with itself. Beyond that, the tests are mostly attacks: a body altered after signing, a wrong secret, a missing header, a signature over the bare body where the provider signs a prefixed string, hex where base64 is expected, a swapped Svix message id, and a replayed delivery whose timestamp was moved forward to escape the window — which must fail, or the window is decoration. Every test still passes if the constant-time comparison is replaced with `===`, because the *results* are identical and only the timing differs. Timing is statistical and machine-dependent; a unit test asserting it would be a flake. That property is carried by using Node's native `timingSafeEqual` rather than a hand-rolled loop — a JavaScript loop accumulating XOR looks constant-time and is not guaranteed to be one, since the JIT may reorder it. Stated here because a page claiming test coverage it does not have is worse than one that names the gap. --- ## ratelimit Source: https://theoven.app/docs/bricks/ratelimit/ > Rate limiting that counts across every instance, in Redis or Postgres, with a sliding window. | | | | --- | --- | | **Package** | `@theoven/ratelimit` | | **Adds to context** | nothing — it is middleware | | **Endpoints** | none | | **Creates files** | none | | **Creates tables** | `oven_ratelimit`, with the SQL store only | | **Status** | shipped | ## Install ```bash bun add @theoven/ratelimit ``` ```ts title="src/app.ts" app.use( rateLimit({ limit: 100, window: 60_000, store: redisStore({ url: env.string('REDIS_URL') }), key: (ctx) => ctx.token ?? ctx.ip, }), ) ``` ## What it does Counts requests in one shared place, so the limit you configured is the limit you get. [Core's `rateLimit`](https://theoven.app/docs/reference/middleware/#rate-limiting) counts in one process. Behind a load balancer that means the effective limit is `limit × instances` — three instances and a limit of 100 admits 300 requests. This is the same middleware with the counting moved somewhere every instance can see. The options are identical apart from `store`, so moving between them is one line. ## Endpoints This package adds none. ## Configuration | Option | Default | | | --- | --- | --- | | `limit` | — | **required**; requests allowed per window | | `window` | `60000` | window length in ms | | `store` | — | **required**; where counting happens | | `key` | client IP | `(ctx) => string \| undefined` — key on a user or API key instead | | `skip` | — | `(ctx) => boolean`, to exempt internal traffic | | `onStoreError` | `'allow'` | what to do when the store is unreachable | Keying on `ctx.token ?? ctx.ip` is usually right: signed-in users get their own budget rather than sharing one with everyone behind the same NAT. Returning `undefined` from `key` skips the check. There is no way to attribute the request, and counting everyone under one bucket would rate-limit the whole world together — worse than not limiting at all. ## Stores | | Counting | Atomic by | | --- | --- | --- | | `redisStore` | one Lua script, one round trip | the script running as a unit | | `sqlStore` | Postgres | `INSERT … ON CONFLICT DO UPDATE … RETURNING` | | `memoryStore` | this process | nothing shared — development only | ### Atomicity is the entire contract `RateLimitStore` has **one method** — `hit()`, which counts and returns the total — and that is deliberate. A store offering `get` and `set` separately cannot be used correctly: ```ts const current = await store.get(key) // both instances read 99 await store.set(key, current + 1) // both write 100 ``` A limit of 100 has admitted 101 requests, and it only happens under the load the limit exists for. So the contract is "count this and tell me the total", and every store owes that atomically. There is a test for it: twenty concurrent hits must produce the counts 1 through 20, with none repeated. A naive read-then-write store returns `1` twenty times. ## Sliding window, not fixed A fixed window lets someone spend the whole allowance in the last second of one window and again in the first second of the next — **2× the limit across every boundary**, which is exactly when a scraper is hammering you. Each check weights the previous window by how much of it still overlaps the trailing window and adds the current one. The weighting assumes the previous window's requests were spread evenly through it. They may not have been, so a count can be slightly high or low near a boundary. The exact alternative is a sorted set of every request timestamp — accurate, and it stores a member per request per key, which on a busy endpoint is a great deal of memory for a limiter. The approximation is within a few percent and costs two integers. ## What it creates **Redis:** keys under the configured `prefix` (default `oven:ratelimit`), expiring on their own. **Postgres:** one table, `oven_ratelimit`, created at boot if absent. Redis keys expire. Rows do not — they stop being *counted*, but they stay, so a busy endpoint grows a row per key per window forever. `pruneRateLimits(client)` deletes the settled ones; run it from a [cron job](https://theoven.app/docs/bricks/queue/#cron). ## When the store is unreachable ```ts rateLimit({ limit: 100, store, onStoreError: 'deny' }) ``` **Fails open by default.** A Redis outage should not take the application down with it — the limiter is a protection, not a dependency. That is a real choice rather than an obvious one. On an endpoint that exists to stop abuse — a login, a password reset, an expensive AI call — failing open means the abuse succeeds for the duration of the outage. Use `'deny'` there. Either way the failure is logged, because a limiter that silently stopped limiting is worse than one that is down. ## Capabilities | | | | --- | --- | | Shared counting across instances | ✓ Redis, SQL | | Atomic increment | ✓ all stores | | Sliding window | ✓ all stores | | `RateLimit-*` headers, including on a `429` | ✓ | | Per-route limits | ✓ via `app.use('/path', …)` | | Distributed token bucket / GCRA | ✗ | | Automatic cleanup of SQL rows | ✗ — `pruneRateLimits` from cron | ## Limitations - **One window, one limit per middleware.** Tiered limits — 10/second *and* 1000/hour — mean registering it twice. - **No token-bucket burst allowance.** A sliding window smooths bursts rather than budgeting them. - **The SQL store costs two queries** per request: the atomic increment, then the previous window. Redis does both in one script. - **`memoryStore` counts per process**, which is the problem this package exists for. It is for development parity only. - **Clock skew between instances** shifts window boundaries slightly. Harmless at a 60s window; worth knowing at 1s. ## How it is verified Both stores run the **same conformance suite** — the shape [`describeAuthStore`](https://theoven.app/docs/bricks/auth/) established, because two implementations answering to one suite is what makes a contract a contract rather than a description of whichever was written first. The suite's most important test is concurrency: twenty simultaneous hits must yield twenty distinct counts. Redis is tested against a **real server** when `REDIS_TEST_URL` is set, and skips with a printed notice otherwise. One bug the tests caught while being written: the `RateLimit-*` headers were set with `ctx.set`, which a thrown error discards — so a `429` carried `Retry-After` and none of the others, dropping them at the exact moment a client needs them. They go on the error now. --- ## vector Source: https://theoven.app/docs/bricks/vector/ > Vector search behind one contract — an embedded scan, pgvector, or Qdrant, with the same score on all three. | | | | --- | --- | | **Package** | `@theoven/vector` | | **Adds to context** | `ctx.vector` | | **Endpoints** | none | | **Creates files** | `./vectors.db` with the SQLite store | | **Creates tables** | `oven_vectors` (SQLite, pgvector) · collections (Qdrant) | | **Status** | shipped | ## Install ```bash bun add @theoven/vector ``` ```ts title="src/app.ts" export const app = createApp().use(vector(sqliteVector({ url: './vectors.db' }))) ``` ```ts title="src/routes/search.post.ts" export const body = z.object({ question: z.string().min(1) }) export default async ({ body, vector }) => { const embedding = await embed(body.question) return vector.query(embedding, { k: 5, filter: { source: 'handbook' } }) } ``` ## What it does Stores embeddings and finds the nearest ones. Defaults to SQLite so retrieval works with **nothing to provision** — the same argument as the SQLite database default (D21) — and moving to pgvector or Qdrant is one line. ## Endpoints This brick adds none. ## Stores | | Search | Provision | | --- | --- | --- | | `sqliteVector` | scans in process | nothing | | [`pgVector`](https://www.npmjs.com/package/@theoven/vector-pg) | pgvector index | Postgres + extension | | [`qdrantVector`](https://www.npmjs.com/package/@theoven/vector-qdrant) | HNSW, server-side | a Qdrant | ### `score` means the same thing on all three Cosine similarity: **1 identical, 0 unrelated, −1 opposite.** Higher is always more similar. That normalisation is deliberate and it is most of what makes this a contract. Engines disagree — pgvector's `<=>` is a *distance* where lower is better, Qdrant returns a similarity where higher is. Forwarding each untouched would make `score` mean the opposite thing depending on which store was registered, so a threshold tuned in development would silently invert in production. Run against the same three vectors, all three engines agree to three decimal places: ``` sqlite same=1.000 close=0.914 ortho=0.000 qdrant same=1.000 close=0.914 ortho=0.000 pgvector same=1.000 close=0.914 ortho=0.000 ``` ## Configuration ```ts vector(sqliteVector({ url: './vectors.db' }), { namespace: 'documents' }) ``` | Option | Default | | | --- | --- | --- | | `namespace` | `'default'` | used when a call does not name one | A namespace is the unit of separation — a Qdrant collection, a column in SQL. Per tenant, per corpus, per environment. Naming one explicitly is how a staging index stays out of a production search. ## Usage ```ts await ctx.vector.upsert([ { id: 'handbook#1', embedding, metadata: { source: 'handbook', page: 1 } }, ]) const matches = await ctx.vector.query(embedding, { k: 5, filter: { source: 'handbook' }, minScore: 0.75, }) await ctx.vector.remove(['handbook#1']) await ctx.vector.count() // in the default namespace await ctx.vector.query(embedding, { namespace: 'tenant-42' }) ``` Upsert **replaces** by id rather than erroring, which is what makes re-indexing a corpus safe to run twice. ### Filters are equality, deliberately ```ts { filter: { source: 'handbook', draft: false } } ``` Every engine supports this. Past it they diverge hard — Qdrant has nested boolean trees, Pinecone has `$gt` and `$in`, pgvector has all of SQL. Inventing a query language over that would be worse than all three and permanently behind each, so anything richer goes through [`raw`](https://theoven.app/docs/reference/bricks/#raw--the-escape-hatch). ## What it creates **SQLite:** `./vectors.db` by default, holding one `oven_vectors` table. `.gitignore` it. **pgvector:** the `vector` extension and an `oven_vectors` table, created at boot. **Qdrant:** one collection per namespace, created on demand with cosine distance. ## Capabilities | | sqlite | pgvector | qdrant | | --- | --- | --- | --- | | upsert, query, remove, clear, count | ✓ | ✓ | ✓ | | Equality filters | ✓ | ✓ | ✓ | | Namespaces | ✓ | ✓ | ✓ | | Approximate index | ✗ — exact scan | ✓ | ✓ | | Learns dimensions from the first write | ✓ | ✗ — required | ✗ — required | | Arbitrary string ids | ✓ | ✓ | ✓ *(mapped to uuids)* | ## Limitations - **The embedded store scans, and `sqlite-vec` is why.** It cannot be loaded under Bun — the bundled SQLite is built without dynamic extension support, and `load()` fails outright. So the store compares in JavaScript. Measured at 1536 dimensions: **3.4 ms at 1,000 vectors, 17 ms at 10,000, 88 ms at 50,000, 359 ms at 200,000.** Linear, fine to roughly 50,000, and past that you should change the store rather than tune this one. - **One embedding model per namespace.** Mixing dimensions is refused with a `VectorDimensionError` rather than stored, because two models' vectors are not comparable and a silent mix makes every later query quietly wrong. - **pgvector's index is not created for you.** The table works without one by scanning; an `ivfflat` or `hnsw` index is a decision about recall and build time that belongs to you. - **No hybrid search, reranking or sparse vectors.** Reach through `raw`. - **No Pinecone adapter yet.** The contract fits it; nobody has written it. - **Metadata is flat.** Strings, numbers and booleans — nested objects are not filterable on every engine, so they are not in the contract. ## How it is verified All three stores run the **same conformance suite**, and that is the point: an in-process scan, Postgres with an extension, and a hosted HTTP service are about as structurally different as three implementations get. One suite over all three is what makes `VectorStore` a contract rather than a description of whichever was written first. pgvector and Qdrant are tested against **real servers** in containers, gated on `PGVECTOR_TEST_URL` and `QDRANT_TEST_URL`, skipping with a printed notice otherwise. Everything below passed on SQLite and failed elsewhere, which is the argument for writing the second adapter at all: - **The conformance suite leaked a connection per test**, because it never closed the store. An in-process store never shows this; Postgres refused the run partway through with *"sorry, too many clients already"*. - **pgvector stored metadata as a jsonb *string*** rather than an object, because Bun.SQL encodes a JS object into jsonb itself and re-encodes a string you hand it. `jsonb_typeof` returned `string`, `@>` containment matched nothing, and **every metadata filter silently returned no results** with no error anywhere. - **A JS array passed to `= any($1)`** serialises as `a,b`, which Postgres rejects as a malformed array literal. --- ## ai Source: https://theoven.app/docs/bricks/ai/ > Everything around an AI SDK call — SSE streaming, abort on disconnect, caching, token accounting and budgets. | | | | --- | --- | | **Package** | `@theoven/ai` | | **Adds to context** | `ctx.ai` | | **Endpoints** | none | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/ai ai @ai-sdk/openai ``` `ai` is a peer dependency, and so is the provider package. You choose the model; this brick never picks one for you. ```ts title="src/app.ts" export const app = createApp().use(ai({ model: openai('gpt-4o-mini') })) ``` ```ts title="src/routes/chat.post.ts" export const body = z.object({ prompt: z.string().max(2000) }) export default (ctx) => ctx.ai.stream({ prompt: ctx.body.prompt }) ``` That route streams tokens to the browser as server-sent events, stops paying the moment the user closes the tab, and records what it cost. ## What it does **It deliberately does not wrap the AI SDK's model layer.** Providers, prompts, tools and structured output are the SDK's job, it does them well, and putting our names on them would age badly — and would teach a coding model nothing, since it already knows `generateText` cold. It is the same reasoning behind [the database brick](https://theoven.app/docs/bricks/db/): `ctx.db` *is* the Drizzle client rather than a wrapper over it, and `ctx.ai.generate` is `generateText` with the infrastructure attached. What is genuinely missing when you call the SDK straight from a route is everything around the call, and each piece is something people rediscover in production: | | Without the brick | | --- | --- | | Streaming to a browser | hand-rolled SSE framing, usually without heartbeats | | Client disconnects | the provider keeps generating, and keeps billing | | The same prompt twice | paid for twice | | The same prompt twice *at once* | paid for twice, and no cache prevents it | | What did that cost? | token counts scattered across call sites, or nothing | | A runaway agent loop | discovered on the invoice | ## Configuration ```ts title="src/app.ts" export const app = createApp() .use(cache(redisCache({ url: env.string('REDIS_URL') }))) .use( ai({ model: anthropic('claude-sonnet-4-5'), embeddingModel: openai.embedding('text-embedding-3-small'), // Uses the cache brick registered above. Pass `store` for anything else with get/set. cache: { ttl: 3_600_000 }, // Currency units per one million tokens. Yours, not ours — see below. pricing: { 'claude-sonnet-4-5': { input: 3, output: 15, cachedInput: 0.3 }, 'text-embedding-3-small': { input: 0.02, output: 0 }, }, budget: { tokens: 50_000 }, onUsage: (record) => { metrics.increment('ai.tokens', record.usage.totalTokens ?? 0, { model: record.model }) }, }), ) ``` | Option | Type | Default | What it does | | --- | --- | --- | --- | | `model` | `LanguageModel` | — | Default chat model. Any call may override it. | | `embeddingModel` | `EmbeddingModel` | — | Default model for `embed()`. | | `cache` | `boolean \| object` | off | `true` or an object turns caching on. | | `cache.store` | `{ get, set }` | the `cache` brick | Where cached answers live. | | `cache.ttl` | `number` | none | Milliseconds. Absent means it never expires on its own. | | `pricing` | `Record` | — | Per million tokens, keyed by model id. | | `budget.tokens` | `number` | — | Token ceiling per request. | | `onUsage` | `(record) => unknown` | — | Called after every call, cache hits included. | ### Why there is no built-in price table A shipped price table is wrong the week a provider changes a price, and *silently* wrong — the number still looks authoritative in your dashboard. Yours lives in your repo next to the rest of your billing assumptions, where a stale figure shows up in review. Without `pricing`, `cost` is `undefined` everywhere. Not `0`. Every token count the AI SDK reports is typed `number | undefined`, because plenty of providers do not report usage at all and some report it only on non-streaming calls. This brick keeps the distinction all the way through. A missing count stays `undefined` and a cost that cannot be computed stays `undefined` — because an always-zero cost column is worse than an empty one. Nobody investigates a zero. ## Usage ### Generate ```ts const { text, usage, cost, cached, raw } = await ctx.ai.generate({ prompt: 'Summarise this ticket in one line.', }) ``` `raw` is the full AI SDK result — tool calls, steps, response metadata. It is **absent on a cache hit**, because a cached entry holds text and token counts, not a live result object, and fabricating one would hand you an empty `toolCalls` array that reads as "the model called nothing" rather than "this was never asked". In practice a call using tools is never cached at all, so `raw` only ever goes missing on a plain text generation. ### Stream ```ts title="src/routes/chat.post.ts" export default (ctx) => ctx.ai.stream({ prompt: ctx.body.prompt }) ``` The wire format is deliberately small, so anything can read it — `EventSource`, curl, a mobile client: ``` event: delta data: {"text":"Hel"} event: delta data: {"text":"lo"} event: done data: {"text":"Hello","usage":{"inputTokens":11,...},"cost":0.000045,"cached":false} ``` ```ts title="client" const events = new EventSource('/chat') events.addEventListener('delta', (e) => (output.textContent += JSON.parse(e.data).text)) events.addEventListener('done', (e) => events.close()) ``` For a client built on the SDK's own UI hooks, use `streamRaw` and its protocol instead. That format carries tool calls, reasoning parts and structured output, and a reimplementation here would only be a worse copy that drifts: ```ts export default (ctx) => ctx.ai.streamRaw({ prompt: ctx.body.prompt }).toUIMessageStreamResponse() ``` Accounting still happens — `streamRaw` attaches it whether or not you read `usage` yourself. ### Embed ```ts const { embedding } = await ctx.ai.embed({ value: document }) await ctx.vector.upsert([{ id, embedding, metadata: { document } }]) ``` Embeddings are the one place caching is unambiguously correct: the same text always embeds to the same vector, so a cache hit is not an approximation of the answer, it *is* the answer. This pairs directly with [the vector brick](https://theoven.app/docs/bricks/vector/). ### Knowing what a request spent ```ts export default async (ctx) => { const result = await ctx.ai.generate({ prompt: ctx.body.prompt }) ctx.log.info('answered', ctx.ai.usage) // { inputTokens, outputTokens, totalTokens, cost, calls, cachedCalls } return { text: result.text } } ``` `ctx.ai` is bound to the request it is on. Its totals, budget and abort signal are that request's, not the process's — a second request never sees the first one's numbers. **These totals count what was actually spent.** A call served from the cache adds to `calls` and `cachedCalls` but contributes no tokens and no cost, because none were billed. That matters beyond tidiness: the budget is checked against these totals, so counting cache hits would let a request answered entirely from cache exhaust an allowance it never spent. On a cache hit `cost` is `0` — free, which is a different thing from the `undefined` that means *cannot be known*. For a call you make through the SDK directly, fold it into the same accounting: ```ts const result = await generateText({ model: ctx.ai.model, tools, prompt }) ctx.ai.record(result.usage, 'generate', 'gpt-4o') ``` ### Outside a request Queue workers and scripts get the same service from the app, and can take their own ceiling: ```ts const worker = app.service('ai').scope({ budget: 20_000 }) await worker.generate({ prompt }) ``` ## Capabilities **What it does:** - Streams as SSE, with heartbeats, and aborts the provider call when the client disconnects. - Caches `generate`, `stream` and `embed`, keyed on the model id and every option that can change the answer. - Collapses identical concurrent calls into one provider call. - Normalises token usage across AI SDK 5, 6 and 7, whose usage shapes differ. - Prices calls when you supply rates, including provider-side prompt caching at its own rate. - Enforces a per-request token budget, and refuses with a `429`. **What fails, and when:** | Situation | What happens | | --- | --- | | No model configured | `AiConfigurationError`, a `500` naming the missing model | | `cache` requested, none available | fails at **boot**, naming the fix | | Budget already spent | `AiBudgetExceededError`, a `429` carrying `spent` and `budget` | | `onUsage` throws or rejects | logged as a warning; the request succeeds | | Provider fails mid-stream | an `error` event on the stream — the `200` is already committed | That last row is the one to design around. `stream()` returns a `Response`, so anything knowable beforehand — no model, budget exhausted — is thrown before the response exists and becomes an ordinary problem document. Once the first token is written the status is already `200`, and a provider failing after that can only be reported in-band. ## Limitations - **A budget bounds how many calls a request makes, not how large any one of them is.** Token counts are unknown until the provider answers, so the check happens before each call, not during. A single call can overshoot. `maxOutputTokens` is the per-call ceiling. - **Caching makes generation deterministic for the TTL.** Correct for classification, extraction and embeddings; wrong for anything a user expects to vary between attempts. It is off unless configured, and any call can opt out with `cache: false`. - **Calls carrying tools are never cached.** A tool holds an `execute` function, and two tool sets differing only in what that function does would otherwise share a key — serving one caller another's answer. The same applies to binary prompt content. - **In-flight deduplication is per process.** Ten concurrent identical prompts on one instance become one provider call; ten instances make ten. - **Budgets are per scope, not per user.** A per-user monthly allowance needs storage and a reset policy, which is an application decision. Use `onUsage` to record, and check your own total before calling. - **No model, provider or prompt abstraction**, by design. Switching providers is an AI SDK concern, and this brick stays out of it. ## How it is verified 43 tests, no network and no API key — the AI SDK ships mock models, so every test drives a real `generateText` and `streamText` through the real mapping layer rather than a stand-in for it. What they actually prove: - **Usage normalisation across SDK versions.** The v7 nested shape and the v5 flat shape both produce the same counts, and a provider reporting nothing produces `undefined` rather than `0` — asserted explicitly, because that single defaulting mistake is what turns a bill into a row of zeroes. - **Pricing**, including provider-cached input billed at its own rate (the test pins the difference at an order of magnitude on a cache-heavy workload) and a guard against a negative charge when a provider reports more cache reads than input tokens. - **Cache keys** are order-independent, differ across prompt, model and operation, ignore options that cannot change the answer, and *refuse* to key a call carrying a function. - **The stampede.** Ten concurrent identical generations against a cold cache reach the provider exactly once. - **Request isolation.** Two requests through the same app report their own totals — the test fails with `30` instead of `15` if the accumulator is ever shared. - **The budget is a `429` problem document, not a `200` with an error event** — which is what it degrades into if the check is moved inside the stream producer, and the test catches exactly that move. - **Cache hits are free, and the budget knows it.** A request making one live call and three cached replays against a 20-token budget succeeds and reports 15 tokens — it fails if cache hits are ever folded back into spend. - **Disconnect cancels the provider.** A real server, a real `fetch`, a real abort, asserting the signal reached the model and fired — because that signal is the only thing that stops a provider from billing, and a stream that merely *looks* stopped is still being paid for. Every one of these was confirmed to fail when the behaviour it covers is deliberately broken. --- ## image Source: https://theoven.app/docs/bricks/image/ > Resize, convert and guard uploads on Bun.Image — no native module, and a decompression bomb refused in under a microsecond. | | | | --- | --- | | **Package** | `@theoven/image` | | **Adds to context** | `ctx.image` | | **Endpoints** | none | | **Creates files** | none | | **Creates tables** | none | | **Status** | shipped | ## Install ```bash bun add @theoven/image ``` Requires **Bun 1.4 or newer**, which is where `Bun.Image` arrived. There is no native module to compile and nothing else to install. ```ts title="src/app.ts" export const app = createApp().use(image({ format: 'webp' })) ``` ```ts title="src/routes/avatar.post.ts" export const auth = true export default async (ctx) => { const [file] = (await ctx.files()).avatar if (!file) throw new BadRequest('Expected an "avatar" file.') const avatar = await ctx.image.transform(file, { width: 256, height: 256 }) await ctx.storage.upload(`avatars/${ctx.user.id}.webp`, avatar.bytes) return { ok: true } } ``` ## What it does Resizes, rotates, flips and re-encodes images, and **refuses the ones that would hurt you**. That second half is the reason this is a brick rather than a note telling you to call `Bun.Image` yourself. Image handling is one of the few places where taking a request at face value is a denial-of-service vector, because the cost lives in the *decoded* size and the request only shows you the compressed one. A 4000×4000 PNG of flat colour compresses to about 70 KB. Nothing about that request looks unusual. Decoding it allocates the full bitmap, and a handful arriving together will end a small server — this is the classic decompression bomb, and a byte limit does not see it coming. Reading the header first costs **0.6 µs**; decoding costs **44 ms**. Header reads are constant time, decoding is linear in pixels, so the bigger the attack the wider the margin. Every method here reads the header and checks it against your limits before anything is decoded. It also **never upscales unless asked**. `Bun.Image` will happily render a 200 px upload at 8000 px, spending real time and memory inventing detail that is not in the source; here the source's own dimensions are the ceiling unless you pass `allowUpscale`. ## Endpoints This brick adds no endpoints. ## Configuration ```ts title="src/app.ts" export const app = createApp().use( image({ format: 'webp', quality: 82, maxBytes: 25 * 1024 * 1024, maxPixels: 40_000_000, // In development, behave exactly like the Linux server will. backend: Bun.env.NODE_ENV === 'production' ? undefined : 'bun', }), ) ``` | Option | Type | Default | What it does | | --- | --- | --- | --- | | `format` | `'jpeg' \| 'png' \| 'webp' \| 'avif' \| 'heic'` | keep the source's, when portable | Default output format. Checked at boot. | | `quality` | `number` | `82` | 1–100. Ignored by PNG, which is lossless. | | `maxBytes` | `number` | 25 MB | Reject a larger encoded upload, before decoding. | | `maxPixels` | `number` | 50,000,000 | Reject a larger decoded image, from the header. | | `backend` | `'system' \| 'bun'` | platform default | Pin the codec backend. See below. | ### Portability, which will bite you otherwise `Bun.Image` runs on one of two backends. `system` is the default on macOS and Windows and uses the operating system's codecs. `bun` is what **every Linux server** runs. | format | `system` (macOS, Windows) | `bun` (Linux) | | --- | --- | --- | | JPEG, PNG, WebP | yes | yes — **byte-identical** | | AVIF, HEIC | yes | **no** | So a developer on a Mac who configures `format: 'avif'` gets a working laptop and a production deployment that rejects every image. This brick encodes one pixel at **boot** to check, and refuses to start with a message naming the format, the backend and the fix — so the failure lands on the deploy rather than on a user's upload weeks later. The three portable formats are byte-identical across backends, which is verified in the test suite rather than assumed. That is what makes `backend: 'bun'` in development worth doing: you get exactly the bytes production will produce, not something similar. ## What it creates Nothing. No files, no tables, no migrations. ## Usage ### Read before you decide ```ts const meta = await ctx.image.metadata(file) // { width, height, format, pixels, bytes } if (meta.width < 512) throw new BadRequest('Please upload an image at least 512px wide.') ``` Cheap enough to call on every upload — that is the point. ### Transform ```ts const result = await ctx.image.transform(file, { width: 1024, // height follows the aspect ratio format: 'webp', quality: 82, }) result.bytes // Uint8Array, ready to upload result.width // 1024 result.height // whatever the ratio gave result.source // what it was before: { width, height, format, pixels, bytes } ``` | Option | What it does | | --- | --- | | `width`, `height` | Give one and the other follows the source ratio. | | `fit` | `inside` (default) fits within the box; `fill` stretches to it exactly. | | `allowUpscale` | Permit an output larger than the source. Off by default. | | `format`, `quality` | Override the brick's defaults for this call. | | `rotate` | Degrees clockwise. 90 and 270 swap width and height. | | `flip`, `flop` | Mirror vertically or horizontally. | ### A `srcset` ladder ```ts const sizes = await ctx.image.variants(file, { widths: [320, 640, 1280] }) for (const size of sizes) { await ctx.storage.upload(`photos/${id}-${size.width}.webp`, size.bytes) } ``` Each width is a separate decode — pipelines do not share one — so a three-width ladder costs about **83 ms**. Put it in a queue job rather than a request: ```ts title="src/jobs.ts" export const processUpload = defineJob<{ key: string }>({ name: 'process-upload', retries: 3, handler: async ({ payload, log }) => { // A job handler gets `{ payload, signal, log }`, not a request context — so the bricks come // from the app itself, which is the same configured instance the routes use. const storage = app.service('storage') const image = app.service('image') const original = storage.download(payload.key) for (const size of await image.variants(original, { widths: [320, 640, 1280] })) { await storage.upload(`${payload.key}-${size.width}.webp`, size.bytes) } log.info('variants written', { key: payload.key }) }, }) ``` ### Blur-up placeholders ```ts const lqip = await ctx.image.placeholder(file) // "data:image/png;base64,iVBORw0KGgoAAAANSUhE…" ``` A [ThumbHash](https://github.com/evanw/thumbhash)-rendered blur of the source at ≤32 px, roughly 400–700 bytes — small enough to inline into the HTML that references the real image, so the layout settles and something is on screen before the full picture is requested. Drops straight into `` or Next's `blurDataURL`. Costs about 7 ms. ## Capabilities **What it supports:** resize (with or without aspect ratio), rotate by multiples of 90°, flip and flop, format conversion between JPEG, PNG and WebP everywhere and AVIF and HEIC on macOS and Windows, quality control, ThumbHash placeholders, and metadata without decoding. **What fails, and when:** | Situation | What happens | | --- | --- | | `format` unwritable on this platform | fails at **boot**, naming the format and backend | | Upload over `maxBytes` | `ImageTooLargeError` — `413`, before decoding | | Image over `maxPixels` | `ImageTooLargeError` — `413`, read from the header | | Not an image, or an unreadable format | `UnsupportedImageError` — `415` | | AVIF or HEIC requested at runtime on Linux | `UnsupportedImageError` — `415`, naming the portable formats | Both are ordinary [problem documents](https://theoven.app/docs/reference/errors/), so a route needs no special handling: ```json { "type": "https://theoven.app/errors/image-too-large", "title": "Image too large", "status": 413, "width": 4000, "height": 4000, "pixels": 16000000, "maxPixels": 1000000 } ``` ## Limitations - **AVIF and HEIC cannot be produced on Linux.** The `bun` backend has no encoder for them. Not something we can fix, which is why it is checked at boot. - **`variants()` decodes once per width.** Cost is linear in the number of widths. Queue it. - **No cropping, compositing, text, watermarks or colour management.** `Bun.Image` does resize, rotate, flip and brightness/saturation, and this brick does not invent the rest. Anything more is a different tool. - **No SVG.** It is not a raster format, and it is a script-execution surface besides. - **No animated output.** GIF decodes; the encoders here write a single frame. - **`quality` does nothing for PNG.** PNG is lossless. It is deliberately not remapped onto `compressionLevel` — those are different scales, and silently reinterpreting one as the other would make `quality: 10` mean "barely compress" instead of "small file". ## How it is verified 24 tests, no fixtures — images are generated as BMPs in the test file, so a test can ask for exactly the dimensions it needs, including sizes far too large to commit to a repository. What they actually prove: - **The bomb is refused.** A 4000×4000 PNG under 200 KB is rejected on pixel count, with the header read asserted to be at least 20× cheaper than the decode. It was ~2500× when measured; the assertion is deliberately loose so it reports a real regression rather than machine noise. - **Upscaling is off.** Asking for 4000 px from a 200 px source returns 200 px — and returns 4000 px only when `allowUpscale` is passed. - **The portable formats are byte-identical on both backends**, compared buffer-to-buffer. This is what makes `backend: 'bun'` a real guarantee rather than a hope. - **AVIF on the Linux backend fails at boot**, with the message asserted to name both the portable formats and the platforms that can do better. The test pins the backend to `bun` on purpose — which is how a macOS machine reproduces a Linux failure at all. - **An oversized upload through a real route is a `413` problem document**, not a crash. Every one of these was confirmed to fail when the behaviour it covers is deliberately broken. ======================================================================================== # Reference ======================================================================================== --- ## App Source: https://theoven.app/docs/reference/app/ > createApp, route registration, configuration, and the server lifecycle. ```ts title="src/app.ts" export const app = createApp() await loadRoutes(app, `${import.meta.dir}/routes`) export default app ``` ```ts title="src/index.ts" await app.listen(3000) ``` That split is what lets `oven routes`, `oven openapi`, `oven worker` and your tests import the app without binding a port. The scaffold does it for you. ## Registering routes ```ts app.get('/users', handler) app.post('/users', { body: schema }, handler) app.put('/users/:id', handler) app.patch('/users/:id', handler) app.delete('/users/:id', handler) app.options('/users', handler) app.head('/users', handler) app.route('GET', '/users', handler) // any method by name ``` Each takes an optional schema between the path and the handler. With one, `ctx.params`, `ctx.query` and `ctx.body` are typed and validated: ```ts app.get('/users/:id', { params: z.object({ id: z.uuid() }) }, (ctx) => ctx.params.id) ``` Most applications use [file-based routing](https://theoven.app/docs/reference/file-routing/) instead and never call these directly. `HEAD` is served from the matching `GET` route, and `OPTIONS` is answered from the methods a path actually has — neither needs registering. Registering the same method and path twice throws at registration rather than silently letting the last one win. ## Middleware and bricks ```ts app.use(requestLogger()) // middleware, everywhere app.use('/admin', requireAdmin()) // middleware, scoped to a prefix app.use(db(drizzleSqlite({ url }))) // a brick app.use(adminRouter) // a router ``` `.use()` takes all three, and tells them apart with no ambiguity: a function is middleware, a router is recognised by its brand, anything else is a brick. A brick contributes to the context and its type flows through the chain — so `ctx.db` exists after that line and is a compile error before it. A [router](https://theoven.app/docs/reference/routers/) contributes routes, with its prefix, tags and auth applied. See [Middleware & hooks](https://theoven.app/docs/reference/middleware/) and [Bricks](https://theoven.app/docs/reference/bricks/). ### `app.override(dependency, resolver)` Replaces a [dependency](https://theoven.app/docs/reference/dependencies/) for this app — the testing seam: ```ts app.override(currentTenant, () => ({ id: 'test-tenant', plan: 'pro' })) const response = await app.fetch(new Request('http://x/dashboard')) app.clearOverrides() ``` It applies wherever the dependency is used, including as a sub-dependency of another, so replacing one root swaps out everything derived from it without touching those definitions. ## Configuration ```ts createApp({ port: 3000, development: process.env.NODE_ENV !== 'production', trustProxy: true, }) ``` | Option | Default | | | --- | --- | --- | | `port` | `PORT`, then `3000` | port for `listen()` | | `hostname` | Bun's default | interface to bind | | `logger` | built-in | swap in pino, winston, anything satisfying `Logger` | | `logLevel` | `info` | level for the built-in logger | | `development` | `NODE_ENV !== 'production'` | controls what reaches the client on an error | | `onError` | — | your error handler; see below | | `shutdownTimeout` | `10000` | ms `close()` waits for in-flight requests | | `requestIdHeader` | `x-request-id` | header consulted for an inbound id | | `echoRequestId` | `true` | echo the id on responses | | `body` | — | total size, per-file size, file count, MIME allowlist | | `query` | depth 5, 1000 keys | query parsing limits | | `cookies` | — | cookie settings, notably the signing secret | | `token` | cookie `token`, query `access_token` | where `ctx.token` is looked for | | `trustProxy` | `false` | whether `X-Forwarded-For` may set `ctx.ip` | | `validateResponses` | `development` | a response that does not match its schema is a `500` | | `serializeResponses` | `true` | a response schema's parsed output becomes the body | Two of those are security settings rather than conveniences, and both default to the safe side: `development` decides whether internal error messages and stacks reach the client. It follows `NODE_ENV`, so a production deploy is safe without you remembering. `trustProxy` is off because `X-Forwarded-For` is client-supplied. Turning it on without a proxy you control lets callers choose their own IP — and walk past any rate limit or audit log keyed on `ctx.ip`. Pass a number to say how many hops to skip. These two are separate on purpose. `serializeResponses` is on **everywhere**, because a response schema filtering the body is a safety property, not a check: Zod strips keys it does not declare, so a row carrying a `passwordHash` cannot send one. Only routes that declare a schema pay for it — about 480 ns each, and nothing on routes that do not. `validateResponses` governs only what happens when the value **fails** to parse: a `500` in development, and in production a logged `filtered: false` with the request still succeeding. Failing closed in production would turn a drifted schema into an outage on the deploy that introduced it. See [response schemas](https://theoven.app/docs/reference/validation/#response-validation). ## Errors ```ts createApp({ onError: (error, ctx) => { if (error.status >= 500) reportToSentry(error, { requestId: ctx.id }) // Return nothing and the default problem+json response is used. }, }) ``` Without a handler, thrown errors become [RFC 9457](https://theoven.app/docs/tutorial/errors/) `problem+json`. Return a value from `onError` to replace that response; return nothing to keep it. ## Lifecycle ### `app.ready()` Runs every brick's `setup()` — connecting the database, compiling models, mounting auth routes. `listen()` calls it for you; call it directly when dispatching requests in tests. ### `app.listen(port?)` Starts the server and returns Bun's `Server`. Installs signal handlers so `SIGTERM` drains rather than kills. ### `app.close(options?)` Stops accepting connections, waits for in-flight requests, then releases every brick's resources in reverse order. Requests arriving mid-drain get `503` with `Retry-After` rather than a dead socket. ```ts await app.close({ timeout: 5000 }) ``` ### `app.fetch(request)` Runs the whole pipeline — routing, middleware, brick hooks, validation, guards, the handler, serialisation — and returns a `Response`. No port is bound. This is how you [test](https://theoven.app/docs/guides/testing/) an Oven app. ```ts const response = await app.fetch(new Request('http://localhost/users/1')) ``` ## Inspection ### `app.routes()` Every registered route as `{ method, pattern }`. Backs `oven routes` and the boot banner. ### `app.service(name)` A brick's contributed service, outside a request. ```ts await app.ready() const db = app.service('db') // the Drizzle client, typed const queue = app.service('queue') ``` `ctx.db` is where an application wants it. A migration script, a seed and `oven worker` all want the same configured value with no request in sight — and re-constructing the brick would give them a *second* connection pool. Typed from what `.use()` contributed, so a name you never registered is a compile error. ### `app.logger` · `app.url` The base logger, and the URL the server is listening on (`undefined` before `listen()`). ## Hooks ```ts app.onRequest((ctx) => { /* before routing */ }) app.beforeHandle((ctx) => { /* after validation, before the handler */ }) app.afterHandle((ctx, result) => { /* transform the result */ }) app.onResponse((ctx, response) => { /* the finished Response */ }) app.onError((error, ctx) => { /* every thrown error */ }) ``` Returning a value from `onRequest` or `beforeHandle` short-circuits the request. See [Middleware & hooks](https://theoven.app/docs/reference/middleware/) for the ordering and what each is for. --- ## Context Source: https://theoven.app/docs/reference/context/ > The single argument every handler receives — request, response, and whatever your bricks contributed. `ctx` is the one argument every handler, middleware and hook receives. ```ts app.get('/users/:id', (ctx) => { ctx.log.info('looking up', { id: ctx.params.id }) return ctx.db.select().from(users).where(eq(users.id, ctx.params.id)) }) ``` Two things shape the whole design. **Everything expensive is lazy.** A handler that returns a string parses no body, no query string and no cookies, and generates no request id. This is not an aspiration — it is asserted by tests that count reads of the underlying request. **Its type is what you registered.** `ctx.db` exists because you added the database brick; leave it out and touching it is a compile error, not a crash at 3am. ## Request ### `ctx.req` The raw web-standard [`Request`](https://developer.mozilla.org/docs/Web/API/Request). Never wrapped, always available — anything the framework does not expose, you can reach here. ### `ctx.params` Path parameters from the matched route, as strings. ```ts // src/routes/users/[id]/posts/[postId].get.ts ctx.params.id // string ctx.params.postId // string ``` Declare a `params` schema and they are typed and coerced instead: ```ts export default route({ params: z.object({ id: z.uuid() }) }, (ctx) => ctx.params.id) ``` A catch-all segment `[...key]` puts the remainder under that name — `ctx.params.key`. A bare `*` in a programmatically registered route lands under `ctx.params['*']`. ### `ctx.path` The pathname, e.g. `/users/42`. Answered from a substring rather than by building a `URL`, because routing needs it on every request. ### `ctx.method` The request method, uppercase. ### `ctx.url` The parsed [`URL`](https://developer.mozilla.org/docs/Web/API/URL), built on first read and reused. Reach for it when you want the origin, hash or raw `searchParams`; `ctx.path` and `ctx.query` cover the common cases without constructing one. ### `ctx.query` The query string, parsed lazily, with arrays and nested keys. ```ts // ?tag=a&tag=b&page[size]=20 ctx.query.tag // ['a', 'b'] ctx.query.page // { size: '20' } ``` Values are strings — a query string has no types. Declare a `query` schema to coerce them: ```ts export default route( { query: z.object({ page: z.coerce.number().default(1) }) }, (ctx) => ctx.query.page, // number ) ``` `__proto__`, `constructor` and `prototype` are refused as keys, and nesting is capped at five levels with a 1000-key limit. A query string is attacker-controlled input, and prototype pollution through it is a real bug class rather than a theoretical one. ### `ctx.header(name)` One request header, or `undefined`. Case-insensitive. ```ts ctx.header('content-type') ``` Returns `undefined` rather than `null`, which reads better beside every other optional on the context. ### `ctx.accepts(...types)` Content negotiation. Returns the best of the offered types for this client's `Accept` header, or `undefined` when none is acceptable. ```ts switch (ctx.accepts('application/json', 'text/html')) { case 'text/html': return page() default: return data() } ``` Quality values are honoured, and a request with no `Accept` gets your first offer. ### `ctx.ip` The client address, or `undefined` when a `Request` was dispatched directly rather than served over a socket. Any client can send that header. Believing it without a proxy in front means clients choose their own IP — and with it, defeat any rate limit or audit log keyed on `ctx.ip`. Set `trustProxy: true` for one proxy, or a number for how many hops to skip. ## Body and uploads ### `ctx.body` The parsed body, awaited. Content-type aware: JSON, urlencoded, multipart, text and raw. ```ts const body = await ctx.body ``` With a `body` schema it is validated and typed, and you do not await it: ```ts export default route( { body: z.object({ title: z.string().min(1) }) }, (ctx) => ctx.body.title, // string, already validated ) ``` Uploads arrive as web [`File`](https://developer.mozilla.org/docs/Web/API/File) objects, streamed rather than buffered, with size and MIME limits applied before your handler runs. ```ts export default route( { body: z.object({ file: z.file().max(5_000_000) }) }, (ctx) => ctx.storage.upload(`uploads/${ctx.body.file.name}`, ctx.body.file), ) ``` ### `ctx.rawBody` The unparsed bytes, as an `ArrayBuffer`. For webhooks that sign the raw payload — re-serialising parsed JSON produces different bytes and a signature that never verifies. ```ts const raw = await ctx.rawBody const valid = verifySignature(raw, ctx.header('stripe-signature')) ``` Reading it does not prevent reading `ctx.body` afterwards. ## Cookies ### `ctx.cookies` Parsed on first touch. ```ts ctx.cookies.get('session') // string | undefined ctx.cookies.has('session') // boolean ctx.cookies.all() // Record ctx.cookies.set('session', token, { maxAge: 3600 }) ctx.cookies.delete('session') ``` Cookies set through `ctx.cookies` are `httpOnly` and `SameSite=Lax` by default, and `Secure` outside development — so shipping to production does not quietly downgrade every session cookie. Signing is available without a separate package. ## Credentials ### `ctx.token` The credential, captured from `Authorization`, then the `token` cookie, then the `?access_token=` query parameter — in that precedence. Present **with or without** an auth brick installed. ```ts if (ctx.token) { /* … */ } ``` Both fallbacks are configurable, and the query one can be switched off entirely: ```ts createApp({ token: { cookie: 'session', query: null } }) ``` Query strings end up in access logs, browser history and `Referer` headers. `?access_token=` is supported because `EventSource` and some download flows cannot set headers — not because it is a good idea. `ctx.tokenSource` lets you refuse it where it does not belong. ### `ctx.tokenSource` Where the token came from: `'header'`, `'cookie'` or `'query'`. ```ts // Refuse a credential that arrived somewhere it could be logged. if (ctx.tokenSource === 'query') throw new Unauthorized('Send the token as a header.') ``` ### `ctx.tokenScheme` The `Authorization` scheme, when the credential came from a header — `Bearer`, `Basic`, and so on. ### `ctx.basicAuth` Decoded `Basic` credentials, when that is what arrived. ```ts const creds = ctx.basicAuth // { username, password } | undefined ``` ### `ctx.user` Present when the [`auth`](https://theoven.app/docs/bricks/auth/) brick is registered. `Identity | null` on an open route; **narrowed to non-null** inside a route declaring `auth: true` or a policy name. ```ts export default route({ auth: true }, (ctx) => ctx.user.id) // no null check needed ``` ## Observability ### `ctx.id` A stable id for this request. Adopted from `x-request-id` when a proxy already set one, so a trace survives the hop; generated otherwise. Generated **lazily** — an app that never logs never pays for a UUID — and echoed on the response only when something actually read it. ### `ctx.log` A `Logger` with `requestId` bound to every line, derived on first read. ```ts ctx.log.info('charging card', { amount: 1200 }) ctx.log.error('gateway refused', { code }) ``` Every error response also carries the request id, so a user's screenshot is enough to find the log line. ## Response ### `ctx.status` Set it to choose the status. Left unset, the returned value decides: `null` becomes `204`, everything else `200`. ```ts ctx.status = 201 ``` ### `ctx.set(name, value)` · `ctx.append(name, value)` Set or append a response header. Both chainable. Use `append` for headers that legitimately repeat, such as `set-cookie`. ```ts ctx.set('cache-control', 'no-store').set('x-thing', '1') ``` ### `ctx.redirect(location, status?)` Builds a redirect response. `302` unless you say otherwise. ```ts app.get('/old', (ctx) => ctx.redirect('/new', 301)) ``` ### `ctx.respond(value)` Coerces a value into a `Response` using this context's status and headers. The server calls it for you; it is exposed because middleware occasionally needs to build the response early. ### `ctx.responseHeaders` The headers staged so far, or `undefined` if none have been set. Mostly of interest to middleware inspecting what a handler did. ## What bricks add Everything above is always there. Each brick you register adds its own, typed: | Brick | Adds | | --- | --- | | [`db`](https://theoven.app/docs/bricks/db/) | `ctx.db` — the native ORM client | | [`auth`](https://theoven.app/docs/bricks/auth/) | `ctx.user`, `ctx.auth` | | [`storage`](https://theoven.app/docs/bricks/storage/) | `ctx.storage` | | [`queue`](https://theoven.app/docs/bricks/queue/) | `ctx.queue` | | [`mail`](https://theoven.app/docs/bricks/mail/) | `ctx.mail` | Writing your own is [about fifty lines](https://theoven.app/docs/reference/bricks/). ## Outside a request `app.service('db')` reaches the same configured value with no request in sight — for a migration script, a seed, or `oven worker`. See [App](https://theoven.app/docs/reference/app/#appservicename). --- ## File-based routing Source: https://theoven.app/docs/reference/file-routing/ > The filesystem is the route table. ``` src/routes/index.get.ts GET / src/routes/users/index.get.ts GET /users src/routes/users/index.post.ts POST /users src/routes/users/[id].get.ts GET /users/:id src/routes/users/[id].patch.ts PATCH /users/:id src/routes/files/[...path].get.ts GET /files/*path src/routes/admin/_middleware.ts middleware for /admin and below ``` ```ts title="src/index.ts" const app = createApp() await loadRoutes(app, `${import.meta.dir}/routes`) await app.listen() ``` ## `loadRoutes(app, dir)` ```ts title="src/index.ts" await loadRoutes(app, `${import.meta.dir}/routes`) ``` Scans the directory, reads every route module, and registers what it finds. Three things about it are worth knowing. **It is awaited, and must finish before the first request.** Routes are registered as they are read, so calling `listen()` without awaiting this serves 404s for however long the scan takes. **`import.meta.dir`, not a relative path.** A relative path resolves against the process's working directory, so the app works when started from the project root and fails from anywhere else. **In production it does not scan at all.** `oven build` writes a manifest of static imports and installs it; `loadRoutes` uses that instead of touching the filesystem. Your code is identical in both, which is the only way the two stay in agreement — see [Production builds](#production-builds). Registration order is deterministic: files are sorted before they are read, so two machines produce the same route table. A route conflict that reproduces only on a colleague's laptop is a miserable thing to chase. ## Conventions | Filename | Meaning | | --- | --- | | `name.get.ts` | `GET /name` | | `index.get.ts` | `GET` for the containing directory | | `[id].get.ts` | a path parameter, `:id` | | `[...path].get.ts` | a catch-all, `*path` | | `_middleware.ts` | middleware for this directory and below | | `_anything.ts` | private — never a route | | `*.test.ts` | ignored, so tests can sit beside routes | The method lives in the filename rather than in named exports, so a route's method is visible in a directory listing and in `git log --stat`. One file is one endpoint, and grepping for `users/[id].patch` finds it. ## Two ways to write a route Both work. They differ in how much typing you get: ```ts title="separate exports — simple, untyped ctx" export const auth = true export const body = z.object({ name: z.string() }) export default async (ctx) => ({ hello: ctx.body.name }) ``` ```ts title="defineRoute — one call, ctx typed from the schema" export default defineRoute( { auth: true, body: z.object({ name: z.string() }) }, async (ctx) => ({ hello: ctx.body.name }), // ^ typed: string ) ``` The exports Oven reads are `params`, `query`, `body`, `headers`, `response`, `auth`, `summary`, `description` and `tags` — the same keys `defineRoute` takes. Anything else exported is ignored. Use the separate-export form when a route is small and you do not mind an untyped `ctx`; use `defineRoute` — or [`routesFor`](#typing-ctx-with-your-bricks) — when you want the schema to type the handler. In `0.1.2`, `export const auth` was collected into nothing and the route answered **anonymously with a 200**. `defineRoute({ auth: true }, …)` was unaffected. **Fixed in 0.1.3** — upgrade, or use `defineRoute` if you are pinned to an older version. ## A route file ```ts title="src/routes/users/[id].patch.ts" export default defineRoute( { summary: 'Update a user', tags: ['users'], params: z.object({ id: z.uuid() }), body: z.object({ name: z.string().min(1) }), response: { 200: UserSchema }, }, async (ctx) => { // Both are typed from the schemas above. const user = await db.user.update(ctx.params.id, ctx.body) if (!user) throw new NotFound() return user }, ) ``` Those schemas drive validation, the handler's types, and the [generated OpenAPI document](https://theoven.app/docs/bricks/openapi/) — one declaration, three jobs. It would read more nicely to write `export const body = ...` beside a separate `export default (ctx) => ...`. TypeScript cannot connect those: nothing relates one module export to another, so `ctx` would be untyped and the inference that makes schemas worth writing would stop at the file boundary. Pairing them in one call is what keeps it. A route with no schemas needs none of this — `export default () => 'pong'` is still a complete route file. ## Middleware A `_middleware.ts` applies to its directory and everything beneath it: ```ts title="src/routes/admin/_middleware.ts" export default async (ctx, next) => { if (!ctx.token) throw new Unauthorized() return next() } ``` Outermost first: a root `_middleware.ts` wraps everything, and `admin/_middleware.ts` wraps `/admin` and below. Prefixes respect segment boundaries, so `/admin` never matches `/administrators`. ## Errors name the file A convention only helps if breaking it says so clearly: ``` users.ts: route files must name their method, e.g. "users.get.ts". Prefix the file with "_" if it is not a route. [..path].get.ts: "[..path]" looks like a mistyped catch-all. Write it as "[...path]". users.get.ts: the default export is string, not a function. Export the handler itself, not the result of calling it. ``` Files are sorted before registration, so two machines produce the same route table. A route conflict that only reproduces on a colleague's laptop is a miserable bug to chase. ## Production builds `oven build` writes a manifest with static imports rather than scanning at boot: ```ts title=".oven/routes.ts (generated)" export function registerRoutes(app: App): void { /* ... */ } ``` Two reasons. Production should not walk the filesystem on every cold start, and a bundler can only tree-shake route modules it can see imported by name. ## Typing `ctx` with your bricks `defineRoute` types `ctx.params`, `ctx.query` and `ctx.body` from the schema beside them. It cannot know what **bricks** your app registered, though — a route file and `app.ts` are separate modules, and nothing connects them. So `ctx.db` in a route file is `unknown`. Bind it once per project: ```ts title="src/route.ts" export const route = routesFor() ``` ```ts title="src/routes/notes/index.get.ts" export default route( { query: z.object({ limit: z.coerce.number().int().max(100).default(20) }) }, (ctx) => ctx.db.select().from(notes).limit(ctx.query.limit), // ^ the Drizzle client, typed from your schema ) ``` The import of `app` is **type-only**, so it does not create a cycle with the module that loads these files — `app.ts` loads the routes, and the routes only reference its type. Use `defineRoute` when a route touches no bricks; `route` everywhere else. They are the same function, one with the context bound. ## When the filesystem is not enough File routing cannot express one guard shared across a group, the same routes mounted under two prefixes, or routes shipped from a package. [Routers](https://theoven.app/docs/reference/routers/) can, and the two compose — most apps use file routing for their own endpoints and a router for the exceptions. --- ## Routers Source: https://theoven.app/docs/reference/routers/ > Group routes, mount them anywhere, and share a prefix, tags and an auth requirement across them. ```ts title="src/routes/admin.ts" export const admin = routerFor({ prefix: '/admin', tags: ['admin'], auth: 'staff' }) admin.get('/users', (ctx) => ctx.db.select().from(users)) admin.delete('/users/:id', { params: z.object({ id: z.uuid() }) }, (ctx) => remove(ctx.params.id)) ``` ```ts title="src/app.ts" export const app = createApp().use(db(...)).use(auth(...)).use(admin) ``` Every route in that file is mounted under `/admin`, tagged `admin` in the OpenAPI document, and guarded by the `staff` policy — declared once. ## What a router is A container for routes, with no lifecycle of its own. It holds them until something mounts it, which is what makes it safe to build at module scope, mount twice, or export from a package. `app.use(router)` registers everything it holds. After that the routes are ordinary routes: validated, guarded, documented, and visible in `oven routes`. ## Defaults | Option | Applies | | --- | --- | | `prefix` | prepended to every path, and to every nested router's prefix | | `tags` | added to every route's OpenAPI tags | | `auth` | the requirement for every route in the group | | `deps` | [dependencies](https://theoven.app/docs/reference/dependencies/) every route in the group resolves | Deliberately small. A router says what its routes have in common; anything that varies per route belongs on the route, where the reader is already looking. ### Tags accumulate, everything else is overridden ```ts const admin = router({ prefix: '/admin', tags: ['admin'] }) admin.get('/users', { tags: ['users'] }, handler) // -> tags: ['admin', 'users'] ``` A route under `/admin/users` is plausibly both, and dropping either would lose a grouping someone asked for. `auth` is not like that — two requirements are not more true than one, so the route's own wins. ```ts const admin = router({ auth: 'staff' }) admin.get('/reports', handler) // auth: 'staff' admin.get('/audit', { auth: 'owner' }, handler) // auth: 'owner' ``` One public route inside an otherwise guarded group is the case worth knowing about: ```ts const admin = router({ auth: 'staff' }) admin.get('/health', { auth: false }, () => ({ ok: true })) // stays public ``` The merge checks whether the route *declared* `auth`, not whether the value is truthy — so an explicit `false` is respected rather than swallowed. There is a test named for this, because a `||` in the wrong place would guard the route and nothing would look wrong. ### Dependencies accumulate too ```ts const admin = routerFor({ prefix: '/admin', deps: { currentTenant } }) admin.get('/usage', (ctx) => report(ctx.deps.currentTenant)) admin.get('/billing', { deps: { invoice } }, (ctx) => ({ tenant: ctx.deps.currentTenant, // from the router invoice: ctx.deps.invoice, // from the route })) ``` Like `tags` and unlike `auth`, these **accumulate** with a route's own — a router dependency and a route dependency are both wanted, and replacing one with the other would leave a handler reading something nothing resolved. On a name collision the route wins. This is the equivalent of FastAPI's `APIRouter(dependencies=[...])`, and it is a good way to apply one guard to a group: a dependency that throws refuses every route under the router. A child router is built before it is nested, so its handler signatures cannot retroactively gain what the parent declares. The dependency still **resolves** — inheritance is real — but `ctx.deps.fromParent` is untyped in the child. A child that wants them typed declares them itself; they de-duplicate by name, so nothing resolves twice. ## Nesting ```ts const users = router({ prefix: '/users', tags: ['users'] }) users.get('/:id', handler) const admin = router({ prefix: '/admin', tags: ['admin'], auth: 'staff' }).use(users) app.use(admin) // GET /admin/users/:id, tags ['admin','users'], auth 'staff' ``` Prefixes join, tags accumulate down the tree, and a child's own `auth` overrides its parent's. ## WebSocket routes ```ts const rooms = routerFor({ prefix: '/rooms', auth: true }) rooms.ws('/:id', { params: z.object({ id: z.uuid() }) }, { upgrade: (ctx) => ({ room: ctx.params.id, userId: ctx.user.id }), open: (socket) => socket.subscribe(socket.data.data.room), message: (socket, text) => app.publish(socket.data.data.room, String(text)), }) app.use(rooms) // GET /rooms/:id, guarded by the router's auth ``` The router's defaults apply, and here that matters more than anywhere else: **a router declaring `auth` guards its sockets too**. A socket endpoint that quietly opted out of its group's guard would be exactly the unguarded back door [`app.ws()`](https://theoven.app/docs/reference/realtime/) exists to prevent. ## Middleware ```ts const admin = router({ prefix: '/admin' }) admin.use(async (ctx, next) => { ctx.log.info('admin request') return next() }) ``` Scoped to the router's prefix, and it wraps the router's routes **regardless of the order** it was declared in relative to them — so a middleware written at the bottom of the file still runs first. Prefixes are segment-aware: `/admin` covers `/admin/users` and never `/administrators`. ## Typing `routerFor()` binds the app's type so `ctx.db`, `ctx.user` and the rest are typed, exactly as [`routesFor`](https://theoven.app/docs/reference/file-routing/#typing-ctx-with-your-bricks) does and for the same reason (D28): a router in its own module cannot see which bricks the app registered, and TypeScript cannot relate sibling modules. The import of `app` is **type-only**, so mounting the router in `app.ts` does not create a cycle. ```ts const health = router({ prefix: '/_health' }) health.get('/live', () => ({ ok: true })) ``` Use plain `router()` when the routes touch no bricks — `ctx` is then just the core context. ## Mounting twice ```ts const api = router() api.get('/ping', () => 'pong') app.use(router({ prefix: '/v1' }).use(api)) app.use(router({ prefix: '/v2' }).use(api)) ``` Because a router holds routes rather than binding to an app, the same one can be mounted more than once. This is the mechanism behind versioned APIs, and behind a package that exports routes. ## Routers or file-based routing? Both, usually. They answer different questions. | | File routing | Routers | | --- | --- | --- | | Grouping | by directory | explicit, in code | | Prefix | the directory path | `prefix` | | Shared `auth` / `tags` | per file | once, for the group | | Mountable twice | ✗ | ✓ | | Shippable from a package | ✗ | ✓ | | Discoverable by reading `ls` | ✓ | you have to find the file | [File-based routing](https://theoven.app/docs/reference/file-routing/) stays the default for an application's own endpoints — the filesystem is a route table everyone can read. Reach for a router when you need something the filesystem cannot express: one shared guard over a group, a set of routes mounted under two prefixes, or routes shipped from a package. ## Inspecting one ```ts admin.size // routes it holds, nested ones included admin.prefix // '/admin' admin.collect() // { routes, middleware } — prefixes joined, defaults applied ``` `collect()` is what `app.use()` calls. It is public so a package can see what it is about to register, and so the merge rules are testable without an app. ## Limitations - **No sub-application mounting.** A router is routes, not a second app: it has no bricks, no lifecycle and no error handler of its own. Mounting a whole `App` inside another — with the questions that raises about whose middleware and whose error handler run — is not supported. - **A nested router's dependencies are not typed for its own handlers** — see above. - **No per-router error handling.** `onError` is app-wide. ## How it is verified The merge rules carry the risk, so they are tested without an app at all: tags accumulating, `auth` overriding, `auth: false` surviving a guarded router, other schema keys untouched, and prefixes joining through nesting. Both of the subtle ones were confirmed to fail when broken — replacing tags instead of accumulating, and merging `auth` with `||` so an explicit `false` is swallowed. That second one is the reason the check reads `'auth' in schema`. Mounting is tested through a real app: every verb, prefix boundaries, validation still applying, middleware scoping, the same router mounted under two prefixes, and the routes appearing in `routeTable()`. --- ## Validation Source: https://theoven.app/docs/reference/validation/ > One declaration that validates the request, types the handler, and documents the endpoint. ```ts export default route( { params: z.object({ id: z.uuid() }), query: z.object({ page: z.coerce.number().int().min(1).default(1) }), body: z.object({ title: z.string().min(1).max(200) }), }, (ctx) => ({ id: ctx.params.id, // string, a valid UUID page: ctx.query.page, // number title: ctx.body.title, // string, 1–200 chars }), ) ``` One declaration does three jobs: it rejects bad input before your handler runs, it types `ctx`, and it becomes the [OpenAPI](https://theoven.app/docs/bricks/openapi/) description of the endpoint. Writing the schema twice — once to validate, once to document — is how the two drift apart. ## What can be validated | Key | Validates | | --- | --- | | `params` | path parameters | | `query` | the query string | | `body` | the request body | | `headers` | request headers | | `response` | what your handler returns, keyed by status | ## Any Standard Schema validator Oven validates through [Standard Schema](https://standardschema.dev), not through Zod. Zod is the default because `z.toJSONSchema()` makes OpenAPI generation free — but nothing is coupled to it. ```ts export default route( { body: v.object({ title: v.pipe(v.string(), v.minLength(1)) }) }, (ctx) => ctx.body.title, // typed, from Valibot ) ``` Valibot, ArkType and Effect Schema all work, and the test suite exercises Valibot alongside Zod precisely so "any Standard Schema validator" is a tested claim rather than a hopeful one. ## Coercion is yours to ask for A query string has no types — every value arrives as a string. Oven does **not** guess: ```ts query: z.object({ page: z.coerce.number().default(1), includeArchived: z.coerce.boolean().default(false), }) ``` Because it cannot know what you meant. Turning `"123"` into `123` before the schema sees it would quietly break a `z.string()` field that legitimately holds digits — an order reference, a postcode, a phone number. `z.coerce` says so explicitly, where you can see it. ## Uploads ```ts export default route( { body: z.object({ title: z.string(), file: z.file().max(5_000_000).mime(['image/png', 'image/jpeg']), }), }, (ctx) => ctx.storage.upload(`uploads/${ctx.body.file.name}`, ctx.body.file), ) ``` `ctx.body.file` is a web [`File`](https://developer.mozilla.org/docs/Web/API/File). Multipart bodies are streamed rather than buffered, and large parts spill to a temporary file — so a schema declaring a file does not mean the whole upload sits in memory first. Limits can also be set once for the whole app, which is the right place for a hard ceiling: ```ts createApp({ body: { limit: 10_000_000, maxFiles: 5, allowedFileTypes: ['image/*'] } }) ``` The app-level limit is enforced during parsing, before any schema runs. A per-route `z.file().max()` narrows it further; it cannot widen it. ## When validation fails A `422`, naming **every** offending field at once rather than the first: ```json { "type": "about:blank", "title": "Unprocessable Content", "status": 422, "detail": "Request validation failed.", "errors": [ { "location": "body", "path": "email", "message": "Invalid email address" }, { "location": "body", "path": "age", "message": "Expected number, received string" } ] } ``` `location` is `params`, `query`, `body` or `headers`; `path` is dotted for nested fields (`address.postcode`) and bracketed for arrays (`items[0].sku`). Assert on `location` and `path` in tests, never on `message` — validator messages get reworded between versions, and a test that breaks when prose changes is a test that gets deleted. ## Response validation ```ts export default route( { response: { 200: z.object({ id: z.string(), title: z.string() }), 404: z.object({ error: z.string() }), }, }, handler, ) ``` Response schemas do three things: shape the OpenAPI document, **filter the response body**, and — in development — fail loudly when a route has drifted from its contract. ### The schema filters the response The parsed output becomes the body, so a field the schema does not declare is not sent: ```ts export default route( { response: { 200: z.object({ id: z.string(), email: z.email() }) } }, async (ctx) => ctx.db.select().from(users).where(eq(users.id, ctx.params.id)), // ^ the row also has passwordHash and resetToken on it ) ``` ```json title="what the client receives" { "id": "1", "email": "ada@example.com" } ``` This is the reason to declare one. `SELECT *` into a handler's return value is how credentials leak, and the schema you wrote for documentation stops it — in production as well as development (D29). `z.object({...}).passthrough()` keeps unknown keys by design, and `z.any()` or `z.unknown()` match anything. Both are legitimate, and neither filters. If the schema is there for safety, make it a plain `z.object`. ### What happens when it does not match | | development | production | | --- | --- | --- | | parses | filtered body | filtered body | | does not parse | `500`, issues in the body | `error` logged, **unfiltered** body, request succeeds | The asymmetry is deliberate. Failing closed in production would turn a schema that has drifted into an outage on the deploy that introduced it — a working endpoint replaced by a `500` over a field nobody reads. So the request survives. But a value that did not parse was not filtered either, so that log line carries `filtered: false`. If a route is logging it, the schema is not protecting anything on that route. `validateResponses` controls the failing half, and defaults to **on in development, off in production**. Set `createApp({ validateResponses: true })` to fail closed in production too. ### Turning it off ```ts createApp({ serializeResponses: false }) ``` The handler's value is sent untouched. Worth knowing before you reach for it: filtering costs about **480 ns** on a route that declares a response schema, and nothing at all on a route that does not. That is roughly 5% of a real request over a socket, and 0.02% of a 2 ms database query. ### A handler that takes control is not validated Returning a `Response`, a `ReadableStream`, a `Blob`, a `Bun.file`, a typed array or a `URL` bypasses response validation entirely: ```ts export default route( { response: { 200: z.object({ id: z.string() }) } }, (ctx) => Bun.file('./report.pdf'), // not checked against the schema ) ``` A response schema describes the JSON body a handler would otherwise have returned. It has nothing to say about a stream or a file, and checking one against it used to produce a `500` on a route that was working correctly. A returned **string is still validated**, because `response: { 200: z.string() }` is a reasonable contract for a text endpoint. ## Validating headers ```ts headers: z.object({ 'x-api-version': z.enum(['1', '2']) }) ``` Header names are matched case-insensitively, as HTTP defines them. ## Where validation sits Ordering matters, and it is fixed: 1. **Middleware** — wraps everything, sees unmatched requests too 2. **Brick `request()` hooks** — `ctx.user` is populated here 3. **`beforeHandle` hooks** — cheap rejections before any parsing cost 4. **Validation** 5. **The handler** A guard rejecting a request never pays to parse a body, and a handler never sees input it did not ask for. ## Without a schema `ctx.body` is still there — parsed, content-type aware, and typed `unknown`: ```ts app.post('/webhook', async (ctx) => { const payload = await ctx.body // unknown; narrow it yourself }) ``` `unknown` rather than `any` on purpose: it forces the narrowing that a schema would have done for you. --- ## Dependencies Source: https://theoven.app/docs/reference/dependencies/ > Per-request, per-route values — composable, cached within a request, with teardown and a testing seam. ```ts const tenant = dependency('tenant', (ctx) => resolveTenant(ctx.header('host'))) const member = dependency('member', async (ctx, use) => { const current = await use(tenant) const found = await findMember(current.id, ctx.user.id) if (!found) throw new Forbidden('Not a member of this tenant.') return found }) export default route({ auth: true, deps: { member } }, (ctx) => ctx.deps.member.role) // ^ typed ``` ## Why not a brick [Bricks](https://theoven.app/docs/reference/bricks/) are **app-level**: built once at boot, present on every request. A dependency is the other scale. | | Brick | Dependency | | --- | --- | --- | | Lifetime | the app | one request | | Applies to | every route | the routes that name it | | Built | once, at boot | per request, cached within it | | Teardown | `onShutdown` | after the request | | Reaches the handler as | `ctx.` | `ctx.deps.` | | Use for | a pool, a client, a service | a tenant, a permission, a transaction | Before this, resolving something per request meant a brick — which then ran on every route in the app — or repeating it at the top of every handler. `ctx.deps.x` rather than `ctx.x` keeps the two namespaces apart, so "where did this come from" is answerable by reading the route (D31). ## Composing A resolver's second argument resolves other dependencies: ```ts const db = dependency('db', (ctx) => ctx.db) const account = dependency('account', async (ctx, use) => findAccount(await use(db), ctx.user.id)) const plan = dependency('plan', async (_ctx, use) => (await use(account)).plan) ``` **Each resolves once per request**, however many others ask for it — `use` caches the promise, not just the value, so two concurrent asks share one resolution rather than racing to start two. Throwing from a resolver rejects the request before the handler runs, which is what makes `member` above a guard as well as a value. ## Teardown An async generator yields its value and cleans up afterwards: ```ts const tx = dependency('tx', async function* (ctx) { const handle = await begin(ctx.db) try { yield handle await handle.commit() } catch (error) { await handle.rollback() throw error } }) export default route({ deps: { tx } }, async (ctx) => { await ctx.deps.tx.insert(orders).values(order) return { ok: true } }) ``` When the request succeeds the generator is resumed normally. When it fails, **the error is thrown in at the `yield`** — which is what lets `catch` tell a rollback from a commit. Because the failure arrives *at the `yield`*, statements after it are skipped on the failure path — ordinary JavaScript, and the same rule as Python's `@contextmanager` that FastAPI's `yield` dependencies are built on. ```ts // Leaks on a failed request: the close is never reached. const bad = dependency('bad', async function* () { const c = await open() yield c await c.close() }) // Always closes. const good = dependency('good', async function* () { const c = await open() try { yield c } finally { await c.close() } }) ``` It is easy to miss because both work on the success path. There is a test named for this. Teardowns run in **reverse order** of resolution, so a dependency can rely on the ones it used still being alive while it cleans up. A teardown that throws on a **successful** request makes that request a `500` — a commit that did not commit means the response about to go out is a lie. On an already-failing request it is logged instead, because replacing the original error with a cleanup error loses the thing worth reading. ## For a whole group A [router](https://theoven.app/docs/reference/routers/) can declare dependencies for every route it holds — the equivalent of FastAPI's `APIRouter(dependencies=[...])`: ```ts const admin = routerFor({ prefix: '/admin', deps: { currentTenant } }) admin.get('/usage', (ctx) => report(ctx.deps.currentTenant)) ``` They accumulate with a route's own rather than replacing them, and a router dependency that throws refuses every route under it — which is a compact way to guard a group. ## Testing ```ts app.override(tenant, () => ({ id: 'test-tenant', plan: 'pro' })) ``` Overrides apply to sub-dependencies too, so replacing `tenant` also changes what `member` sees without touching `member`. `app.clearOverrides()` puts the real ones back. This is the seam dependency injection is supposed to buy: a test swaps what a route depends on without reaching into the route, and without a module mock. ## When they run After validation and after the [brick hooks](https://theoven.app/docs/reference/middleware/#the-order-everything-runs-in), before the handler. So a resolver can read `ctx.params` and `ctx.body` already validated, and `ctx.user` is populated. ## They resolve concurrently A route's declared dependencies start together: ```ts export default route({ deps: { user, subscription, features } }, handler) ``` Three dependencies each doing 10 ms of I/O cost **~11 ms, not 33 ms** — a route waits for the slowest, not the sum. That is the usual shape: a dependency exists because something has to be fetched. Sub-dependencies are still ordered by what needs what — `use()` inside a resolver is awaited normally. To run two of *those* together, say so: ```ts const both = dependency('both', async (_ctx, use) => Promise.all([use(profile), use(settings)]), ) ``` **A failure is reported in declaration order**, not by whichever rejected first. Two dependencies that both fail would otherwise produce different errors on different runs, which makes the same bug look like two. Siblings still get torn down. A dependency that opened a transaction and a sibling that then threw will not leave the transaction open. ## Cycles A dependency that depends on itself, directly or through others, fails with the path rather than a stack overflow: ``` Dependency cycle: account -> plan -> account. ``` It is a `DependencyCycleError`, so an application can tell a configuration mistake from a resolver that threw. A **diamond is not a cycle** — two dependencies sharing a third is the normal case, and it resolves the shared one once. ## Cost Nothing at all on a route that declares none — no scope, no map, no array. | | | | --- | --- | | one dependency | ~590 ns | | two or more | ~+400 ns on top, for the concurrency machinery | | three 10 ms I/O dependencies | ~11 ms, against ~33 ms resolved one at a time | The fixed cost of resolving concurrently is only paid by routes declaring more than one, and it is paid back many times over by anything that touches the network. If two dependencies are both synchronous, they were probably function calls in disguise. The cache and the teardown stack are allocated on first use rather than per request, because the common shape is one dependency with no sub-dependencies and no teardown. ## Limitations - **A nested router's dependencies are not typed for its own handlers.** They resolve, but a child built before it was nested cannot see what its parent declares. Declare them on the child too if you want the types. - **Not in the OpenAPI document.** A dependency that acts as a guard does not appear as a security requirement; use [`auth` with a policy](https://theoven.app/docs/bricks/auth/#policies) for anything that should be documented. - **No app-wide dependencies.** Every route names what it needs. That is the point, but it means no equivalent to FastAPI's `FastAPI(dependencies=[...])`. ## How it is verified The behaviours that carry risk each have a test, and the subtle ones were confirmed to fail when broken: caching (resolves once per request, and concurrent asks share one resolution), reverse teardown order, and the commit-versus-rollback distinction. Two came out of tests that were wrong first. One asserted on a variable nothing ever assigned — it passed while proving nothing, and rewriting it to observe real ordering exposed a **leak**: a dependency that threw part-way through the list left the ones before it open, because resolution ran outside the `try` that disposes them. The other was written with cleanup after `yield` and no `finally`, and failing it is what pinned down the semantics now documented above. --- ## Errors Source: https://theoven.app/docs/reference/errors/ > Throw an error, get a correct HTTP response — as RFC 9457 problem+json, synchronously or not. ```ts export default route({ params: z.object({ id: z.uuid() }) }, async (ctx) => { const [note] = await ctx.db.select().from(notes).where(eq(notes.id, ctx.params.id)) if (!note) throw new NotFound(`No note with id ${ctx.params.id}.`) return note }) ``` There is no `next(err)`. Throw, and the error handler turns it into the right response — whether it happened synchronously or three `await`s deep. Express needs a shim for that half; here it is the only behaviour. ## The classes | | Status | | | --- | ---: | --- | | `BadRequest` | 400 | malformed input the schema did not cover | | `Unauthorized` | 401 | no credential, or one that did not verify | | `Forbidden` | 403 | a credential that is not allowed to do this | | `NotFound` | 404 | | | `MethodNotAllowed` | 405 | raised for you when a path exists under other methods | | `Conflict` | 409 | duplicate key, version mismatch | | `PayloadTooLarge` | 413 | raised for you when a body exceeds its limit | | `UnsupportedMediaType` | 415 | raised for you when a file's MIME is not allowed | | `UnprocessableContent` | 422 | raised for you when validation fails | | `TooManyRequests` | 429 | | | `InternalServerError` | 500 | | | `ServiceUnavailable` | 503 | raised for you when shutting down | All extend `OvenError`. Anything else you throw becomes a `500`. ## The response Every error becomes [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json` — a standard shape, so clients can handle failures generically instead of parsing your prose. ```json { "type": "about:blank", "title": "Not Found", "status": 404, "detail": "No note with id 8f14e45f.", "requestId": "3b9f8c5d-5c9e-497f-8be1-d0d54bd52035" } ``` `requestId` appears once anything has read `ctx.id` — which the request logger does, so it is on every error response in a normally configured app. It is lazy rather than unconditional because an app that never logs should not pay for a UUID per request. A user's screenshot is then enough to find the log line. ### Extra members ```ts throw new Conflict('That email is taken.', { detail: { field: 'email', existingId }, }) ``` `detail` is merged into the document, so per-field information travels with the error rather than being flattened into a sentence. ### Headers ```ts throw new TooManyRequests('Slow down.', { headers: { 'retry-after': '60' } }) ``` Some statuses are only useful with a header: `429` without `Retry-After` leaves the client guessing how long to wait. `Unauthorized` carries `WWW-Authenticate: Bearer` by default, because RFC 9110 says a `401` **must** include a challenge — a client told it was refused, but not how to authenticate, cannot do anything useful. Pass your own `headers` to use a different scheme and yours wins. ## Validation failures A failed schema is a `422` naming every field at once, rather than the first one: ```json { "type": "about:blank", "title": "Unprocessable Content", "status": 422, "detail": "Request validation failed.", "errors": [ { "location": "body", "path": "email", "message": "Invalid email address" }, { "location": "query", "path": "page", "message": "Expected number" } ], "requestId": "…" } ``` `location` is `params`, `query`, `body` or `headers`. Assert on `path` in tests, not on `message` — messages get reworded, and a test that breaks when prose changes gets deleted. ## What reaches the client In development, a `500` carries the original message and stack, because you are the one reading it. In production it does not: the response is a generic `Internal Server Error` with a request id, while the real error — message, stack, cause — goes to your log. This follows `NODE_ENV`, so a production deploy is safe without you remembering. An error message is a fine place for a connection string to leak. Errors you raised deliberately (`4xx`) always keep their message. It is only the ones you did not expect that get redacted. ## Your own errors ```ts export class PaymentRequired extends OvenError { constructor(detail: string, invoiceId: string) { super(402, 'Payment Required', detail, { type: 'https://example.com/problems/payment-required', detail: { invoiceId }, }) } } ``` Setting `type` to a URL you control is what RFC 9457 intends: a stable identifier a client can branch on, and a page a human can read. It stays `about:blank` otherwise. ## Handling them centrally ```ts createApp({ onError: (error, ctx) => { if (error.status >= 500) reportToSentry(error, { requestId: ctx.id }) // Return nothing and the default problem+json response is used. }, }) ``` Return a value to replace the response; return nothing to keep the default. The handler sees every error, already normalised to an `OvenError` with a status. If `onError` itself throws, the framework falls back to the default response and logs both. An error path that can fail closed is not an error path. ## In middleware Middleware sees errors as thrown exceptions on the way out, so a `try`/`finally` works normally: ```ts app.use(async (ctx, next) => { const started = performance.now() try { return await next() } finally { ctx.log.info('handled', { ms: performance.now() - started }) } }) ``` Refusals the framework generates itself — `404`, `405`, `501` — are returned rather than thrown, so middleware still sees them as responses and can add its headers. ## `toOvenError(thrown)` An `onError` handler receives whatever was thrown, which is not necessarily an `OvenError` — a driver can throw a string, and a rejected promise can carry anything at all. ```ts createApp({ onError: (error, ctx) => { const problem = toOvenError(error) // Now `status` is a number, whatever was actually thrown. if (problem.status >= 500) reportToSentry(problem, { requestId: ctx.id }) return undefined // fall through to the default rendering }, }) ``` Anything that is already an `OvenError` is returned unchanged. Anything else becomes a `500` whose message is withheld in production — which is the same normalisation the default handler applies, so using it means your handler and the framework agree about what a thrown string means. --- ## Middleware & hooks Source: https://theoven.app/docs/reference/middleware/ > Onion-model middleware, five lifecycle hooks, and the built-ins that ship configured. ## Middleware Each middleware receives the context and a `next` it may await. Everything before the `await` runs on the way in, everything after runs on the way out. ```ts app.use(async (ctx, next) => { const start = Bun.nanoseconds() const result = await next() ctx.set('server-timing', `total;dur=${(Bun.nanoseconds() - start) / 1e6}`) return result }) ``` There is no `(req, res, next)` and no Connect compatibility. A middleware either returns a value or throws, exactly like a handler — which is what lets the two compose without special cases. ### Short-circuiting Not calling `next()` skips the handler. This is how guards and caches work: ```ts app.use(async (ctx, next) => { if (!ctx.token) throw new Unauthorized() return next() }) ``` Awaiting `next()` twice runs the rest of the chain a second time. The resulting double-write is very hard to trace back to its cause, so Oven throws instead. ### Path scoping ```ts app.use('/admin', requireAdmin) ``` Prefixes respect segment boundaries: `/admin` matches `/admin` and `/admin/users`, but never `/administrators`. ### Order is registration order Middleware runs in the order you register it, outermost first. On the way out, the order reverses — the first registered is the last to see the result: ```ts app.use(securityHeaders()) // 1 in, 4 out app.use(cors({ origin })) // 2 in, 3 out app.use(requestLogger()) // 3 in, 2 out app.use(rateLimit({ limit: 100 })) // 4 in, 1 out ``` The order that usually matters: 1. **`securityHeaders`** first, so even a `404` or a rejected preflight carries them. 2. **`cors`** early, because a preflight it answers should not travel further. 3. **`requestLogger`** before anything that can reject, so rejections are logged too. 4. **`rateLimit`** before expensive work, but after logging — otherwise the requests you most want to see are the ones you never record. 5. **Your own auth or tenant middleware** last, closest to the handler. Brick `request()` hooks run **after** all middleware and before the handler, so `ctx.user` is not yet populated in a middleware. That is deliberate: a middleware that needed the user would be a guard, and guards belong on the route (`auth: true`), where they are visible and documented. ### Middleware wraps routing, not just the handler Middleware runs for **every** request, including ones that match no route. A CORS preflight, a 404 that still needs security headers, a request log that should include misses — none of those work if middleware only runs once a route has matched. ## The order everything runs in ``` Bun.serve fetch │ ├─ build Context ├─ onRequest hooks ← before middleware; returning a value ends it here │ ├─ middleware chain ← in registration order, wrapping everything below │ │ │ ├─ route match ← 404 from here is still inside your middleware │ ├─ brick request() hooks ← ctx.user and other typed per-request state appear │ ├─ beforeHandle hooks │ ├─ validate params, query, body │ ├─ handler │ └─ afterHandle hooks │ ├─ build the Response └─ onResponse hooks ← onError instead, if anything above threw ``` Two consequences worth internalising: - **`ctx.user` is not available in middleware.** Bricks contribute per-request state after the middleware chain has been entered. Guard routes with `auth: true`, not with a middleware. - **A 404 runs your whole middleware chain.** That is what makes CORS and security headers work on requests that match nothing. ## Lifecycle hooks | Hook | When | Returning a value | | --- | --- | --- | | `onRequest` | before **middleware** and routing | short-circuits the request | | `beforeHandle` | after routing, params available | skips the handler | | `afterHandle` | after the handler | replaces the result | | `onResponse` | after the `Response` is built | replaces the response | | `onError` | on any thrown error | replaces the error body | ```ts app.onRequest((ctx) => (maintenance ? 'Back shortly' : undefined)) app.beforeHandle((ctx) => cache.get(ctx.path)) app.afterHandle((_ctx, result) => ({ data: result })) app.onResponse((_ctx, response) => { metrics.record(response.status) }) app.onError((error) => ({ ok: false, code: error.status })) ``` Returning `undefined` from any hook means "carry on unchanged", which is why a hook that only observes needs no return at all. ### Hooks or middleware? They overlap; the difference is what you can see and what you can wrap. | | Middleware | Hooks | | --- | --- | --- | | Wraps the whole request | ✓ — code before *and* after `await next()` | ✗ — one point each | | Can be scoped to a path | ✓ `app.use('/admin', …)` | ✗ — always global | | Sees the matched route | only after `next()` | `beforeHandle` onward | | Can replace the `Response` object itself | ✓ | `onResponse` only | | Runs when nothing matched | ✓ | `onRequest`, `onResponse`, `onError` | Reach for middleware when you need something on both sides of the handler — timing, a transaction, a wrapper. Reach for a hook when you need exactly one point and want it to read as one line. ## Writing one A middleware is a function. There is nothing to extend and nothing to register with: ```ts export function timing(header = 'server-timing'): Middleware { return async (ctx, next) => { const start = Bun.nanoseconds() try { return await next() } finally { // `finally`, so a thrown error is still timed — those are the slow ones. ctx.set(header, `total;dur=${(Bun.nanoseconds() - start) / 1e6}`) } } } app.use(timing()) ``` Two things worth knowing: - **Return what `next()` gave you**, or your own value. Returning nothing means the response is `undefined`, which is a `204` and almost never what was meant. - **Do not catch errors just to log them.** Errors already become RFC 9457 responses and are already logged; a middleware that swallows one turns a `500` into a silent success. Use `finally`, or `onError` if you want to change the body. **A middleware has nowhere to put per-request state.** There is no `ctx.state` bag, deliberately: an untyped bucket on the context is invisible to autocomplete, and anything in it is `unknown` at the point you read it — which is the opposite of [types are the documentation](https://theoven.app/docs/start/philosophy/). When a middleware wants to hand something to the handler, what you want is a [brick](https://theoven.app/docs/guides/writing-a-brick/) with a `request()` hook. It contributes a **typed** property under its own key: ```ts const tenancy = { name: 'tenant', setup: () => ({ resolve }), request: async (ctx) => ({ tenant: await resolve(ctx.header('host')) }), } satisfies Brick<'tenant', TenantService, { tenant: Tenant }> app.use(tenancy) // `ctx.tenant` now exists, typed — and only in apps that registered this brick. ``` That is the same mechanism `auth` uses for `ctx.user` (D15). Use a plain middleware for behaviour that wraps a request; use a brick when the request needs to carry something new. ## Built-ins These ship in the box but stay configurable — the cases where behaviour genuinely varies between apps, unlike body parsing where it does not. **Configured, not installed.** ### CORS ```ts app.use(cors({ origin: ['https://app.example'], credentials: true })) ``` | Option | Default | | | --- | --- | --- | | `origin` | `'*'` | a string, a list, `'*'`, or `(origin) => boolean` | | `methods` | the usual verbs | methods to allow | | `allowedHeaders` | reflects the request's | `Access-Control-Allow-Headers` | | `exposedHeaders` | none | headers the browser may read from the response | | `credentials` | `false` | allow cookies and `Authorization` | | `maxAge` | `86400` | seconds a preflight result may be cached | Preflights are answered with `204`, and a reflected origin always sets `Vary: Origin` so a shared cache cannot serve one origin's response to another. Browsers reject a wildcard origin on credentialed requests, so the combination fails at runtime looking like a server bug. Oven throws when you construct it instead. ### Security headers ```ts app.use(securityHeaders({ contentSecurityPolicy: "default-src 'self'" })) ``` | Option | Default | | | --- | --- | --- | | `contentSecurityPolicy` | off | a CSP string, or `false` | | `hsts` | 1 year | `Strict-Transport-Security` max-age in seconds, or `false` | | `frameOptions` | `DENY` | `'DENY'`, `'SAMEORIGIN'` or `false` | | `referrerPolicy` | `strict-origin-when-cross-origin` | any policy string, or `false` | | `permissionsPolicy` | denies high-risk features | a policy string, or `false` | CSP is **off by default** deliberately: a policy strict enough to be worth having breaks a page that was not built for it, and one loose enough never to break anything is decoration. Write it for your app. Sets `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Permissions-Policy`. HSTS is only sent over HTTPS — sending it on plain HTTP would pin `localhost` to https in your browser for a year. ### Rate limiting ```ts app.use(rateLimit({ limit: 100, window: 60_000, key: (ctx) => ctx.token ?? ctx.ip })) ``` | Option | Default | | | --- | --- | --- | | `limit` | — | **required**; requests allowed per window | | `window` | `60000` | window length in ms | | `key` | client IP | `(ctx) => string \| undefined` — key on a user or API key instead | | `skip` | — | `(ctx) => boolean`, to exempt internal traffic | Keying on `ctx.token ?? ctx.ip` is usually what you want: signed-in users get their own budget rather than sharing one with everyone behind the same NAT. Sets `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`; a rejection carries `Retry-After` and a `429`. Behind a load balancer each instance counts separately, so the effective limit is `limit × instances`. Fine for shedding obvious abuse, wrong for anything you must enforce exactly. [`@theoven/ratelimit`](https://theoven.app/docs/bricks/ratelimit/) counts in Redis or Postgres instead — the same options plus a `store`. ### Request logging ```ts app.use(requestLogger({ ignore: ['/health'], slowThreshold: 500 })) ``` | Option | Default | | | --- | --- | --- | | `ignore` | none | paths to skip — health checks otherwise dominate the log | | `slowThreshold` | `1000` | log at `warn` above this many ms | One structured line per request with the request id already bound. Slow requests log at `warn`, failures at `error`. ### Compression ```ts app.use(compression({ threshold: 1024 })) ``` | Option | Default | | | --- | --- | --- | | `threshold` | `1024` | smallest response worth compressing, in bytes | | `types` | text, JSON, JS, XML, SVG | content types to compress; prefixes allowed | Compresses text-shaped payloads above a threshold. Streaming responses — a returned `Response`, `ReadableStream` or `Blob` — are passed through untouched: buffering them to compress would stop server-sent events arriving and pull a large download entirely into memory. Most Bun deployments sit behind a CDN that already compresses, in which case this is redundant. Measure before reaching for it. --- ## Always-on batteries Source: https://theoven.app/docs/reference/batteries/ > Body parsing, uploads, cookies, token capture and query parsing — with nothing to install. Everything on this page is core behaviour. There is no `app.use()` to call, no order to get right, and no way to forget it. A route that returns a constant never parses a body, never splits a cookie header, never parses the query string, and never generates a request id. Batteries included is worthless if you pay for batteries you do not use — so this is enforced by tests, not good intentions. ## Body `ctx.body` is awaited, and parsed from the request's `Content-Type`. ```ts app.post('/users', async (ctx) => { const body = await ctx.body // parsed, whatever the content type return db.user.create(body) }) ``` | Content-Type | You get | | --- | --- | | `application/json`, `*+json` | the parsed value | | `application/x-www-form-urlencoded` | an object, nesting and arrays kept | | `multipart/form-data` | an object, files as `File` | | `text/*` | a string | | anything else | an `ArrayBuffer` | An empty body is `undefined`, not a throw. Malformed JSON is a `400` before your handler runs. Awaiting twice is safe — the result is memoised, so it never fails on a consumed stream. ### Raw bytes for webhooks `ctx.rawBody` gives the exact bytes that arrived. Webhook signatures cover those bytes, and re-serialising parsed JSON produces different ones — a genuinely miserable thing to debug. ```ts app.post('/webhooks/stripe', async (ctx) => { const raw = await ctx.rawBody verifySignature(raw, ctx.header('stripe-signature')) const event = await ctx.body // still works; parses from the same bytes }) ``` ### Limits ```ts createApp({ body: { limit: 8 * 1024 * 1024, fileLimit: 2 * 1024 * 1024, maxFiles: 10, allowedFileTypes: ['image/*', 'application/pdf'], }, }) ``` The size limit is enforced by **counting bytes as they arrive**, not by reading `Content-Length`. That header is client-supplied and often absent, so a limit that trusts it is not a limit. ## Files Uploads arrive as web `File` objects. Oven does not buffer multipart bodies — Bun spills large parts to temporary files, and buffering first would defeat that. ```ts app.post('/avatar', async (ctx) => { const { avatar } = await ctx.files() const file = avatar?.[0] if (!file) throw new BadRequest('No file uploaded.') return { name: file.name, size: file.size, type: file.type } }) ``` Over the per-file limit is a `413`. Outside the MIME allowlist is a `415`. Filenames are kept exactly as sent and never resolved as paths — a filename is attacker-controlled input. ## Cookies ```ts ctx.cookies.get('session') ctx.cookies.set('session', id, { maxAge: 60 * 60 * 24 }) ctx.cookies.delete('session') ctx.cookies.all() ``` Defaults lean secure: `httpOnly` and `SameSite=Lax` unless you opt out, and `Secure` outside development. `SameSite=None` forces `Secure`, because browsers drop the cookie otherwise and you would never see why. ### Signed cookies ```ts const app = createApp({ cookies: { secret: process.env.COOKIE_SECRET } }) ctx.cookies.set('uid', '42', { signed: true }) ctx.cookies.get('uid', { signed: true }) // undefined if tampered with ``` Signatures are compared in constant time. A tampered cookie reads as **absent** rather than as a value with a warning flag — returning it would invite someone to use it by mistake. A cookie set with `path: '/admin'` must be deleted with the same path, or the browser treats it as a different cookie. This is the usual reason "logout does not work". ## Token capture `ctx.token` is populated on every request, **with or without an auth module installed**. ```ts app.get('/me', (ctx) => { if (!ctx.token) throw new Unauthorized() const claims = jwt.verify(ctx.token, secret) // your verification, your keys return claims }) ``` Capture and verification are different jobs. Pulling a bearer token out of a header is trivial and universal, so core does it. Deciding whether it means anything needs keys or a session store, so `@theoven/auth` does that — and if you do not use our auth module, verifying a third-party JWT should not require installing one. | Property | What it holds | | --- | --- | | `ctx.token` | the credential | | `ctx.tokenSource` | `'header'`, `'cookie'` or `'query'` | | `ctx.tokenScheme` | `Bearer`, `Basic`, … when it came from a header | | `ctx.basicAuth` | `{ username, password }` for `Basic` | Precedence is header → cookie → query. A caller sending an `Authorization` header meant it; a token in a URL is the weakest and most leak-prone form, so it never wins. ```ts createApp({ token: { cookie: 'session', query: null } }) ``` Setting `query: null` disables query capture entirely — worth doing, since query strings reach access logs, browser history and `Referer` headers. ## Query ```ts // ?tag=a&tag=b&filter[status]=open&page=2 ctx.query // { tag: ['a', 'b'], filter: { status: 'open' }, page: '2' } ``` Repeated keys become arrays, `a[b]=1` nests, and `a[]=1` is an explicit array. Depth and key count are capped, and `__proto__`, `constructor` and `prototype` are dropped wherever they appear — this is the `qs` CVE class, and the only safe amount of it is none. ## Environment variables ```ts const port = env.port('PORT', 3000) const debug = env.bool('DEBUG', false) ``` Parsing that throws instead of guessing — `Boolean('false')` is `true`, `Number('')` is `0`, and `parseInt('12abc')` is `12`, all silently. Secrets are redacted from errors and dumps. Bun loads `.env` files itself, so there is no dotenv to install. See [Environment variables](https://theoven.app/docs/reference/env/). ## Headers, IP and negotiation ```ts ctx.header('x-api-version') // undefined when absent, never null ctx.accepts('application/json', 'text/html') ctx.ip ``` `ctx.ip` uses the socket address. It honours `X-Forwarded-For` **only** when you configure `trustProxy`: ```ts createApp({ trustProxy: 1 }) // one proxy hop in front ``` This is a security setting, not a convenience one. Any client can send that header, so trusting it without a proxy in front lets callers choose their own IP — and walk past anything keyed on it, including rate limits and audit logs. The hop count is applied from the right, because a client can prepend as many entries as it likes. --- ## Writing a brick Source: https://theoven.app/docs/reference/bricks/ > The brick contract — how a brick contributes a capability, and how its types reach the context. **A brick is what other frameworks call a plugin.** Bricks build the oven: add one and a capability appears on the request context, fully typed. Take it away and using it is a compile error. This page is the contract, for anyone writing one. For the bricks Oven ships and how to use them, see the [catalogue](https://theoven.app/docs/bricks/). The name is the only piece of invented vocabulary in Oven, and it is deliberate — a brick is a specific thing with a specific contract, not the grab-bag "plugin" has become elsewhere. A brick's `setup()` runs once at boot and returns the thing it provides. That value becomes `ctx.`, and its **type** flows through `.use()` into every handler. ```ts const app = createApp() .use(storage({ driver: 's3', bucket: 'uploads' })) .use(queue({ driver: 'redis' })) app.post('/upload', async (ctx) => { const { url } = await ctx.storage.upload('key', file) // typed await ctx.queue.dispatch('resize', { url }) // typed return { url } }) ``` Leave the queue brick out and `ctx.queue` is a **compile error**, not a crash at 3am. That is the whole point, and the test suite asserts it: registering the brick makes the `@ts-expect-error` on that line unnecessary, which fails the build. ## Two kinds of contribution A brick contributes in two places, and the difference is the whole reason `ctx.user` can exist: | | Runs | Contributes | Cost per request | | --- | --- | --- | --- | | `setup()` | once, at boot | a **shared** service | none — it lives on the prototype | | `request()` | every request | **per-request** state | only what you compute | ```ts const auth = { name: 'auth', setup: () => createClerkClient(...), // shared, once request: async (ctx) => ({ // per request user: await verifyToken(ctx.token), }), } app.get('/me', (ctx) => { ctx.auth.users.get(...) // the shared client, typed ctx.user?.id // per-request state, typed }) ``` `request()` runs **after routing**, so `ctx.params` is available, and **before** middleware, the handler and validation. Throwing rejects the request — which is how an auth brick turns a bad token into a `401` without a handler ever seeing it. It is also handed the matched route: ```ts request: (ctx, route) => { if (route.schema?.auth) requireSignedIn(ctx) } ``` Core never interprets `auth: 'admin'` on a route. It carries it, and the brick decides what it means — which is what keeps auth replaceable instead of built in. Module augmentation would be simpler to write and is what many frameworks do. It also makes `ctx.user` appear in apps that never installed auth — a type that promises something the runtime will not deliver. D5 rejected that for exactly this reason, and `request()` is the answer that keeps the types honest. ## Writing one ```ts export function cache(options: { ttl: number }): Brick<'cache', CacheClient> { return { name: 'cache', setup: async () => connect(options), onShutdown: (client) => client.disconnect(), } } ``` | Field | Purpose | | --- | --- | | `name` | the property added to the context | | `setup(context)` | builds the value; runs once, at boot | | `dependsOn` | brick names that must be set up first | | `onRequest(ctx)` | per-request work, such as loading a session | | `onShutdown(value)` | release pools, workers, connections | ### Shared versus per-request `setup()`'s return value lives on the context **prototype**, so it is shared across requests and costs nothing per request. Anything genuinely per-request — a session, a transaction — belongs in `onRequest`, which runs with the context in hand. ### Dependencies ```ts { name: 'auth', dependsOn: ['db'], setup: (context) => makeAuth(context.resolved.db), } ``` Declaring the dependency rather than relying on registration order means the auth brick finds the database however the user chose to write their config. A cycle throws at boot, naming both bricks — it cannot be resolved at runtime, so failing early beats deadlocking. ### Contributing routes ```ts setup: (context) => { context.route('GET', '/_docs', () => renderDocs()) return { mounted: true } } ``` This is how `/auth/*` and the docs UI will mount themselves. ## `raw` — the escape hatch Every first-party service exposes the thing underneath it: ```ts ctx.storage.raw // the Bun.S3Client, or the directory path ctx.queue.raw // the Bun.RedisClient, or the SQL client ctx.cache.raw // likewise ctx.auth // the provider itself ctx.db // not an escape hatch — `ctx.db` *is* the Drizzle client (D16) ``` This is what makes a small contract acceptable. A contract is only worth having if it is the **genuine intersection** of its implementations — the moment it grows a method that one driver has to fake, it has started lying. So anything a single driver can do and the others cannot stays off the contract, and `raw` is how you reach it: ```ts // S3 can copy an object server-side. Disk, Bunny and ImageKit cannot, so `copy` is not on the // contract — and reaching through `raw` is how you use it anyway. const s3 = ctx.storage.raw as Bun.S3Client await s3.file('archive/report.pdf').write(s3.file('reports/2026-q3.pdf')) ``` Service-level `raw` is typed `unknown`, so the cast above is not an oversight — it is you stating which driver you have assumed. The compiler cannot know, because the whole purpose of the contract is that the driver is swappable. The consequence is worth being deliberate about: **code that reaches through `raw` stops being portable.** It works until someone changes the driver, and then it fails at runtime rather than at build time. Keep those reaches in one file, and check `ctx.storage.driver` if you need to branch: ```ts if (ctx.storage.driver !== 's3') throw new ServiceUnavailable('Copy needs S3.') ``` `user.raw` is the same idea with one difference: it is **typed per adapter** rather than `unknown`, because an auth provider's shape is known at the point you register it. See [`user.raw`](https://theoven.app/docs/bricks/auth/#userraw--the-escape-hatch). ### Declared capabilities, when it is not a one-off `raw` is for reaching past the contract occasionally. When a capability is real but not universal, the answer is to **declare** it and check at boot rather than to make everyone reach: ```ts if (!ctx.storage.canPresign) throw new ServiceUnavailable('Direct upload is not configured.') ``` That is D19, and it is why `auth-clerk` can say out loud that it cannot sign anyone out instead of offering a `signOut()` that quietly does nothing. ## Configuration files `defineConfig` puts the same thing in one declarative file: ```ts title="oven.config.ts" export default defineConfig({ trustProxy: 1, cookies: { secret: process.env.COOKIE_SECRET }, bricks: [storage({ driver: 's3' }), queue({ driver: 'redis' })], }) ``` `appFromConfig()` returns a plain `App`, because a runtime array of bricks cannot express each contribution in the type system. Chain `.use()` directly when you want `ctx.storage` typed at the call site — that is exactly why both surfaces exist rather than one. ## Errors caught at boot - A duplicate brick name. - A name that collides with a built-in context property, which would shadow the real thing in a way that surfaces far from the brick responsible. - A missing or circular dependency. - Registering a brick after the first request, since setup runs at boot. --- ## Environment variables Source: https://theoven.app/docs/reference/env/ > Reading them safely, and validating them at boot. Two tools for two jobs. `env.*` reads one variable with a sensible parse; `defineEnv` validates your whole configuration against a schema. Reaching for a schema to read one optional flag is overkill, and reading forty variables one at a time is worse. Bun already reads `.env`, `.env.local` and `.env.`, and applies the precedence between them. Oven ships no dotenv, because a second implementation with slightly different precedence rules is a genuinely nasty thing to debug. ## Reading one variable ```ts const port = env.port('PORT', 3000) const debug = env.bool('DEBUG', false) const origins = env.list('ALLOWED_ORIGINS', []) const timeout = env.duration('REQUEST_TIMEOUT', '30s') // → 30000 ms const maxUpload = env.bytes('MAX_UPLOAD', '8mb') // → 8388608 const level = env.oneOf('LOG_LEVEL', ['debug', 'info', 'warn', 'error'], 'info') const database = env.url('DATABASE_URL') // throws when unset ``` | Method | Reads | | --- | --- | | `string(name, fallback?)` | a trimmed, non-empty string | | `optional(name)` | a string or `undefined` — never throws | | `raw(name)` | the value untouched | | `number` / `int` | a number; `int` rejects fractions rather than rounding | | `bool` | `true/false`, `1/0`, `yes/no`, `y/n`, `on/off` | | `port` | an integer in 1–65535 | | `url` | a value that actually parses as a URL | | `list` | comma-separated, trimmed, empties dropped | | `oneOf(name, values, fallback?)` | one of a fixed set | | `duration` | `500ms`, `30s`, `5m`, `1h`, `2d`, or bare milliseconds | | `bytes` | `512kb`, `8mb`, `1gb`, or bare bytes (binary units) | | `has(name)` | whether it is set to something non-empty | | `all()` | every variable, with secret values redacted | Plus four **getters**, not methods — no parentheses: ```ts env.nodeEnv // 'development' unless NODE_ENV says otherwise env.isProduction // nodeEnv === 'production' env.isDevelopment env.isTest ``` Anything absent with no fallback, or present but unparseable, throws an error naming the variable. ### Fallbacks are for defaults, not for secrets ```ts const port = env.port('PORT', 3000) // ✓ a sensible default env.string('AUTH_SECRET', 'dev-secret') // ✗ ships a secret every deployment shares env.string('AUTH_SECRET') // ✓ refuses to start without one ``` A fallback turns a missing variable into a silent default. That is right for a port and wrong for anything that authenticates — which is why `basicAuth` takes `secret` with no default and fails at boot rather than inventing one. ## Why not just read `process.env` Because JavaScript's coercions are quietly wrong in exactly the ways environment variables are quietly wrong: ```js Boolean('false') // true — DEBUG=false turns debugging ON Number('') // 0 — an unset PORT becomes port 0 parseInt('12abc') // 12 — a typo'd value is silently truncated ``` None of those produce an error. They produce a service that starts, looks healthy, and behaves wrongly. `env.bool('DEBUG')` on `DEBUG=flase` throws instead, naming the variable. ## Secrets are redacted ```ts env.all() // { PORT: '3000', DATABASE_URL: '[redacted]', STRIPE_SECRET_KEY: '[redacted]' } ``` Names matching `SECRET`, `TOKEN`, `KEY`, `PASSWORD`, `CREDENTIAL`, `PRIVATE`, `SALT`, `DSN`, `DATABASE_URL` and similar are redacted in **both** `all()` and error messages: ``` DATABASE_URL: expected a valid URL, got [redacted] PORT: expected a number, got "12abc" ``` The variable is named either way — that is the useful half. The value only appears when it is safe to show. The reason anyone dumps an environment is to put it somewhere readable later: a log, an error report, a support ticket. Redaction is the default for that reason. Matching is deliberately loose. A false positive redacts something harmless; a false negative puts a production database password into a log aggregator, and there is no taking it back. ## Validating everything at boot ```ts title="src/env.ts" export const env = defineEnv( z.object({ DATABASE_URL: z.url(), PORT: z.coerce.number().default(3000), STRIPE_KEY: z.string().startsWith('sk_'), }), ) env.PORT // number ``` A missing `DATABASE_URL` otherwise surfaces forty minutes into production as `connect ECONNREFUSED undefined:undefined`, from a stack trace pointing at a connection pool rather than at the config. This refuses to start instead: ``` EnvError: Invalid environment: DATABASE_URL: Invalid input: expected string, received undefined STRIPE_KEY: Invalid input: must start with "sk_" ``` Every problem at once — fixing environment configuration one variable per restart is a genuinely awful way to spend twenty minutes. ### Printing it cleanly Left to propagate, an `EnvError` is rendered by Bun like any other uncaught throw: a stack with source context from inside the library, and the line naming your variable somewhere below it. The scaffold therefore catches it in `src/env.ts` and prints the message on its own: ```ts title="src/env.ts" try { loaded = read() } catch (error) { if (error instanceof EnvError) { console.error(`\n${error.message}\n`) process.exit(1) } throw error } ``` ``` Invalid environment: LOG_LEVEL: expected one of debug, info, warn, error, got "verbose" ``` A configuration mistake should read like one. Any [Standard Schema](https://standardschema.dev) validator works here, same as [route validation](https://theoven.app/docs/tutorial/validation/). `defineEnv` runs at module load, before anything else. An async schema would let the rest of the module execute against an unvalidated environment, which defeats the point — so one is rejected rather than silently awaited. --- ## CLI Source: https://theoven.app/docs/reference/cli/ > Scaffold, run, build and inspect Oven apps. ```bash oven create my-app # scaffold a project oven dev # run with a watcher oven build # bundle and write a route manifest oven start # run the build oven db migrate # run schema and migration commands oven worker # run background jobs oven routes # print the route table oven openapi # emit the OpenAPI document oven doctor # check the project for common problems ``` ## `oven create` ```bash oven create my-app --db sqlite --auth basic ``` | Flag | Values | Default | | --- | --- | --- | | `--template` | `minimal`, `api` | `api` | | `--db` | `sqlite`, `postgres`, `none` | asked, or `none` | | `--auth` | `basic`, `none` | asked, or `none` | | `--openapi` | `--openapi` / `--no-openapi` | on | | `--yes` | skip every prompt | — | `--db sqlite` scaffolds **Drizzle over `bun:sqlite`** — no server to run, no native module. The switch to Postgres is one import and one line in `src/db.ts`; every query you have written stays the same. `--auth basic` adds working signup, login, refresh, logout, password change and reset, with the [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) tables re-exported from `src/schema.ts` so `oven db generate` picks them up. Mail defaults to the console driver, so the reset link is printed to your terminal and the whole flow works before you have configured a provider. Auth implies a database; asking for auth with `--db none` scaffolds SQLite and says so. Every project also gets an **`AGENTS.md`** describing the conventions — route naming, return values instead of `res.json()`, `defineRoute`, native ORM queries — and the things never to do, because a model that has not read the docs otherwise reaches for Express habits. ```bash oven create my-app --template api ``` Interactive on a TTY, flag-driven otherwise — a scaffolder that prompts inside CI hangs forever, which is a bad way to find out a pipeline is misconfigured. Pass `--yes` to skip prompts. | Template | Contents | | --- | --- | | `minimal` | one route, nothing else | | `api` | a small REST resource with validation and OpenAPI | It refuses to write into a non-empty directory. A directory holding only `.git` is fine, since `git init` comes first for a lot of people. ## `oven dev` ```bash oven dev --port 4000 ``` Runs the entry under a watcher. Restarts on change rather than patching modules in place: in-place reloading leaves a server bound to the port and brick state half-initialised from the previous version, and a clean restart takes single-digit milliseconds in Bun. The correctness is worth more than the milliseconds. Signals are forwarded, so Ctrl-C reaches the app and its graceful shutdown actually runs. ## `oven build` ```bash oven build --outdir dist ``` Two steps, in this order: 1. **Write a route manifest** — `.oven/routes.ts`, with a static import per route file. 2. **Bundle** with `Bun.build`, targeting Bun. The order matters: the bundler can only include route modules it can see imported by name. A bundle has no `src/routes` directory — `import.meta.dir` points at `dist/`. Rather than make every app write `if (production)` around its route loading, the build generates an entry that installs the manifest before your app module runs, and `loadRoutes` uses it instead of touching the filesystem. Your code is identical in development and production, which is the only way the two stay in agreement. ## `oven routes` and `oven openapi` ```bash oven routes oven openapi > openapi.json oven openapi --out openapi.json --title "My API" --api-version 1.0.0 ``` Both import the **app module** — `src/app.ts` — not the entry. That is why the scaffold splits them: `app.ts` builds and exports the app, `index.ts` calls `listen()`. If inspection imported the entry, asking for a route table would bind a port. With no `--out`, `oven openapi` writes only JSON to stdout, so it can be piped straight into a client generator. ## `oven db` ```bash oven db generate # write a migration from the current schema oven db migrate # apply pending migrations oven db push # push the schema straight to the database (development) oven db studio # open the database browser oven db drop # drop a generated migration ``` These delegate to whatever migration tool your project already uses, detected from its config file — `drizzle.config.ts` means drizzle-kit, `prisma/schema.prisma` means the Prisma CLI. Oven does not reimplement migration generation; a wrapper around it would drift from the real tool within a release. What you get instead is one set of command names whatever adapter you chose, and translation where the tools disagree — `oven db push` runs `drizzle-kit push` in one project and `prisma db push` in another. A command with no equivalent says so rather than running something that looks similar. ### `migrate` runs in-process on Drizzle `generate`, `push`, `studio` and `drop` shell out to drizzle-kit. `migrate` does not, on Drizzle projects: drizzle-kit's SQLite migrator cannot use `bun:sqlite` and asks you to install `better-sqlite3` or `@libsql/client`, which would mean the default Oven stack could *generate* a migration and then fail to apply it. Drizzle ships Bun-native migrators, so `oven db migrate` uses those. It reads the same `drizzle.config.ts` drizzle-kit does, so the two cannot disagree about where migrations live. Anything after `--` is passed through unchanged: ```bash oven db generate -- --name add_orders_table ``` `push` rewrites the schema in place with no migration to review or roll back. That is what you want against a development database and not what you want in a deploy — `oven db push` warns when `NODE_ENV=production`. ## `oven worker` ```bash oven worker # long-running; ctrl-c drains in-flight jobs oven worker --concurrency 20 oven worker --once # drain what is queued and exit ``` Imports your **app module**, so the worker gets the same database, the same mail driver and the same job definitions the app has. A worker configured separately from its app is a worker that drifts from it — and the symptom is jobs dead-lettering as "no handler registered" after a deploy. `--once` is for a cron container or a CI step: it drains what is queued and exits rather than idling. See [`queue`](https://theoven.app/docs/bricks/queue/). ## `oven doctor` ```bash oven doctor ``` ``` ✓ Bun version 1.2.23 ✓ Entry src/index.ts ✓ App module src/app.ts ✓ Routes src/routes ! Environment .env.example present, .env missing Copy it: cp .env.example .env ✓ Port 3000 is free ``` Every check that is not `ok` says what to do about it. A diagnostic that only reports a problem has done half the job. ## Environment variables Not a command, but the same class of problem — catching configuration mistakes at boot rather than in production: ```ts const port = env.port('PORT', 3000) // parses, or throws naming PORT const debug = env.bool('DEBUG', false) // DEBUG=false is false, not truthy ``` `oven doctor` checks that a `.env` and `.env.example` agree. See [Environment variables](https://theoven.app/docs/reference/env/) for the full reader and for `defineEnv`. ## Commands that need a brick `oven db` needs a migration toolchain in the project and `oven worker` needs the [`queue`](https://theoven.app/docs/bricks/queue/) brick registered. Neither guesses: a project without one gets a sentence saying what is missing and what to add, rather than an error that looks like a typo you made. --- ## WebSockets & SSE Source: https://theoven.app/docs/reference/realtime/ > Real-time, upgraded from an ordinary route — so guards, params and validation all still apply. ## WebSockets ```ts app.ws('/rooms/:id', { auth: true, params: z.object({ id: z.uuid() }) }, { upgrade: (ctx) => ({ room: ctx.params.id, userId: ctx.user.id }), open: (socket) => socket.subscribe(socket.data.data.room), message: (socket, text) => app.publish(socket.data.data.room, String(text)), close: (socket) => socket.unsubscribe(socket.data.data.room), }) ``` `app.ws()` registers an **ordinary route** that happens to upgrade. That is the design, and it is the whole reason to use it: - `auth: true` refuses an anonymous connection **before** a socket exists - `params` rejects a malformed `:id` before a socket exists - `ctx.user` is there, populated by whichever auth brick you registered - brick hooks, middleware and request hooks all ran A separate socket entry point means a second, weaker authentication story — and that is how a socket endpoint becomes the unguarded way into an application. Here there is one guard, and it runs first. ### The handlers | | | | --- | --- | | `upgrade(ctx)` | before the socket opens. Return per-connection data; **throw to refuse** | | `open(socket)` | the connection is live | | `message(socket, message)` | a frame arrived — `string` or `Buffer` | | `close(socket, code, reason)` | it went away | | `drain(socket)` | a backpressured socket has room again | | `options` | Bun's `maxPayloadLength`, `idleTimeout`, `backpressureLimit` | Refuse an upgrade by throwing — `throw new Unauthorized()` — exactly as anywhere else, and the client gets the usual problem+json before any socket exists. ### Per-connection data Whatever `upgrade` returns is on every callback: ```ts app.ws('/chat', { upgrade: (ctx) => ({ userId: ctx.user.id, joinedAt: Date.now() }), message: (socket) => { socket.data.data.userId // typed, inferred from upgrade's return socket.data.requestId // the id of the request that opened it }, }) ``` `socket.data.requestId` is the same id as the upgrade request's, so a trace spans both. ### Rooms ```ts socket.subscribe('room:42') app.publish('room:42', JSON.stringify({ text })) socket.unsubscribe('room:42') ``` Bun's own pub/sub, so a broadcast does not walk a list of connections in JavaScript. ### A plain GET A browser visiting a socket route gets `426 Upgrade Required` with an `Upgrade: websocket` header, rather than a confusing 404. ### On a router ```ts const rooms = routerFor({ prefix: '/rooms', auth: true }) rooms.ws('/:id', handlers) ``` A [router](https://theoven.app/docs/reference/routers/) can hold socket routes, and its `prefix`, `tags` and `auth` apply to them — so one declaration guards every socket in the group. ## Server-sent events ```ts app.get('/progress', (ctx) => sse( async (stream) => { for (let percent = 0; percent <= 100; percent += 10) { stream.send({ event: 'progress', data: { percent } }) await Bun.sleep(200) } }, { signal: ctx.req.signal }, ), ) ``` ```js const events = new EventSource('/progress') events.addEventListener('progress', (e) => console.log(JSON.parse(e.data))) ``` ### Which to reach for | | WebSocket | SSE | | --- | --- | --- | | Direction | both ways | server → client | | Protocol | its own, after an upgrade | plain HTTP | | Reconnects | you write it | the browser does it | | Proxies, `Authorization` | occasionally awkward | just works | | Binary | yes | text only | If the client only listens — progress, a feed, tokens from a model — SSE is less to get wrong. ### The stream ```ts stream.send({ event: 'tick', data: { n: 1 }, id: '7', retry: 500 }) stream.send('bare value') // becomes `data: bare value` stream.comment('still here') // a comment the client never sees stream.close() ``` | | | | --- | --- | | `data` | required; objects are JSON-encoded, strings sent as-is | | `event` | the name the client listens for; without one, `onmessage` fires | | `id` | sets `lastEventId`, returned as `Last-Event-ID` on reconnect | | `retry` | how long the client should wait before reconnecting, in ms | `send` returns `false` once the client has gone rather than throwing, so a producer can check `stream.closed` and stop without a `try`. ### Details that matter **Pass `ctx.req.signal`.** Without it, a browser closing the tab leaves the producer running: the writes fail silently and whatever it was computing carries on forever. **Multi-line payloads are split across `data:` lines**, because a raw newline ends an event — the client would see a truncated payload and report nothing. **Heartbeat comments every 15s** by default, since proxies close connections that go quiet. Set `heartbeat: 0` to disable. **A producer that throws sends an `error` event.** The response has already begun, so there is no status left to change; telling the client is the only thing left worth doing. ## Limitations - **No automatic reconnect state.** `Last-Event-ID` arrives as a header; resuming from it is yours to implement. - **`app.publish()` needs a listening server** — it returns `0` when a `Request` was dispatched directly, as in tests. - **No room registry.** Bun's topics are strings; who is in which room is your application's to track if you need to know. - **SSE has no backpressure signal.** A slow client's writes queue; a WebSocket's `drain` gives you the hook that SSE does not. --- ## The public API Source: https://theoven.app/docs/reference/public-api/ > What each package exports, what is deliberately not exported, and why the difference matters to you. Everything a package exports is something it promises to keep working. So the surface is kept to what someone outside the framework has a reason to call — and until 0.5.1, it was not. ## What happened in 0.5.1 Fifty-six exports were removed. Not deprecated, removed: `parseBody`, `scanRoutes`, `toResponse`, `parseCron`, `renderInbox`, `captureToken` and about fifty others. They were never features. They are the framework's own moving parts, exported at some point so a test could reach them, and left exported afterwards. The tests do not even need it — every one of them imports from the module directly (`from './body'`), not from the package index. The exports were doing nothing except sitting in the public surface. **Why that is worth a breaking change rather than leaving them alone:** | | | | --- | --- | | Everything exported is a promise | 149 exports meant 149 things that could not change without a major version — for functions nobody was supposed to call | | Autocomplete is documentation | Typing `import { ` and seeing `redactToken`, `middlewarePrefix` and `assertSendable` teaches nothing except that the list is untrustworthy | | A large surface hides the small one | The eight things you actually need were buried among sixty you did not | If you imported one of them, you were reaching into the framework's internals — which is occasionally the right call, and is now something you do deliberately by importing from the file rather than accidentally because it appeared in autocomplete. A script compared every export against three things: whether any other package imports it, whether it appears in generated code, and whether any page or README mentions it. Only exports failing all three were considered — and then each was kept or dropped on whether a sentence could be written explaining who would call it. Four survived that on the "generated code" test alone; see below. ## The parts you would not guess are public Everything below stayed, because each has a caller outside the framework. Most were undocumented before 0.5.1, which is what made the sweep necessary in the first place. ### Written into your project by `oven build` ```ts ``` You will never type these. `oven build` writes them into `.oven/routes.ts` and the bundled entry, so they are part of the contract between the CLI and core — and removing them would break every built application rather than only code that imported them. `generateManifest` and `generateOpenApi` are the other half: the CLI calls them to produce that manifest and to answer `oven openapi`. ### Tokens, outside the endpoints ```ts // A token for a test, without going through /auth/login. const token = signAccessToken({ sub: user.id }, secret, 900) // Verifying one somewhere the auth brick is not registered — a worker, a second service. const claims = verifyAccessToken(token, secret) // null when expired, tampered, or foreign ``` `verifyAccessToken` returns `null` rather than throwing, for the same reason `verifyPassword` returns `false`: a bad token is a normal event on a public endpoint, not an exception. ### Does this user have a password? ```ts // Someone who signed up with Google has no password, so a settings page should offer // "set a password" rather than "change password". const canChange = !isUnusablePassword(user.raw.passwordHash) ``` This is why [social sign-in](https://theoven.app/docs/bricks/auth/#social-sign-in) needed no breaking migration: a provider-created user gets a sentinel no password can match, rather than a null column. ### Building on the flows ```ts ``` `basicAuth` and `mongoAuth` are thin wrappers over `passwordAuthProvider` plus a store. Call them directly when you want the combination they do not offer — a Drizzle store with a different provider name, or a Mongo store with your own connection handling: ```ts passwordAuthProvider({ name: 'staff', secret, store: drizzleStore(db, { accounts: true }), }) ``` `supportsAccounts(store)` reports whether a store can hold linked accounts — the check the brick itself runs at boot before allowing social sign-in. `authModels(connection)` gives you the compiled Mongoose models, for querying the auth collections alongside your own. ### Your own OAuth endpoints ```ts ``` The two halves of the [social sign-in flow](https://theoven.app/docs/bricks/auth/#social-sign-in), for an application that wants the redirect and callback somewhere other than `/auth/oauth/:provider`. Both take the same config the mounted endpoints use, so the `state`, PKCE and linking rules are identical. ### Writing a driver ```ts ``` For people writing a database provider or a storage driver rather than using one. `rememberProvider` is how [`transaction()`](https://theoven.app/docs/bricks/db/#transactions) finds the right implementation for a client it was handed; `assertSafeKey` is the path-traversal guard every storage driver must apply, exported so a third-party driver applies the *same* one rather than its own idea of what is safe. A storage key comes from a request. `../../etc/passwd` is a key. Every first-party driver calls this before touching a path, and a driver that does not is a directory traversal in something someone will deploy. ### Detecting an upgrade ```ts // A middleware that should not run for WebSocket handshakes — a body-logging one, say. app.use(async (ctx, next) => { if (isUpgrade(ctx.req)) return next() return logBody(ctx, next) }) ``` ## Reaching an internal anyway Nothing stops you. Import it from the file: ```ts ``` It works, and it is honest — the import says plainly that you have reached past the contract, so the next person can see it and a version bump can break it without anyone claiming surprise. The [`raw` escape hatches](https://theoven.app/docs/reference/bricks/#raw--the-escape-hatch) exist for the same reason and carry the same warning. `./src/*` is a real subpath from 0.5.2 onward, and that is the entire distinction from the root export: the root is a promise, this is a door. An internal can be renamed, resplit or deleted in any release, including a patch. It was **not** a real subpath in 0.5.1 — that release documented this and the `exports` map did not allow it, so the import failed. Fixed in 0.5.2. ## The rule going forward An export needs a caller outside the framework, and a sentence explaining who. If neither exists, it stays internal — and `bun run check:docs` now covers every README, so an export that is documented cannot quietly stop being true. ======================================================================================== # Other ======================================================================================== --- ## Build a todo API Source: https://theoven.app/docs/build/ > A guided project — from an empty directory to an authenticated, tested, documented API. Every snippet is real code from a working app. The [tutorial](https://theoven.app/docs/tutorial/first-route/) teaches one idea per chapter. This builds one thing, end to end: a todo API where people sign up, log in, and can only ever see their own todos. By the end you will have: - `POST /auth/signup`, `/auth/login`, and five more auth endpoints you did not write - `GET`, `POST`, `PATCH` and `DELETE` for todos, each scoped to the signed-in user - validation that rejects bad input before your code runs, and documents itself at `/docs` - a test suite that proves one user cannot touch another's data - something you can deploy Every snippet here is copied out of a working application that was built and exercised while the page was written — including the tests, which pass. If something does not work for you, it is a bug in Oven or in these instructions, not a gap you are expected to fill in. ## The chapters 1. **[Set it up](https://theoven.app/docs/build/setup/)** — one command, and a tour of what you get. 2. **[The first endpoint](https://theoven.app/docs/build/first-endpoint/)** — a route, a schema, and why the filesystem is the router. 3. **[Storing todos](https://theoven.app/docs/build/storing-todos/)** — a table, a migration, and real persistence. 4. **[Locking it down](https://theoven.app/docs/build/auth/)** — signup, login, and scoping every query to its owner. 5. **[Testing it](https://theoven.app/docs/build/testing/)** — including the tests that matter most. 6. **[Shipping it](https://theoven.app/docs/build/shipping/)** — what production refuses to boot with, and why. ## What you need Bun 1.4 or newer. Nothing else — no database server, no Redis, no S3 account. The default stack is SQLite on disk and mail printed to your terminal, so the whole thing runs on a laptop on a plane. ```bash bun --version # 1.2.0 or higher ``` ## The finished thing If you would rather read the code than build it, here is the whole app — six files you write, on top of what `bun create` gives you: ``` src/ app.ts configured by the scaffold; you add nothing route.ts binds ctx to your bricks — three lines schema.ts your table, plus auth's routes/todos/ index.get.ts list index.post.ts create [id].patch.ts update [id].delete.ts delete app.test.ts the suite ``` Four route files, a schema, and a test. Everything else — auth endpoints, request parsing, error shapes, OpenAPI, graceful shutdown — you get without writing it. [Start with the setup →](https://theoven.app/docs/build/setup/) --- ## Projects Source: https://theoven.app/docs/projects/ > Three realistic backends, built end to end — a chat app, a Trello clone, and a video platform. [Recipes](https://theoven.app/docs/recipes/) add one feature to an app you have. These are the other end: **a whole backend, built in order**, including the parts that are genuinely hard rather than the CRUD around them. | Project | The hard part | Uses | | --- | --- | --- | | [Chat](https://theoven.app/docs/projects/chat/) | cursor pagination, and a socket that checks membership before it opens | Drizzle, WebSockets, auth | | [Trello](https://theoven.app/docs/projects/trello/) | ordering cards under drag-and-drop without rewriting the list | Drizzle, transactions, policies | | [YouTube](https://theoven.app/docs/projects/youtube/) | an upload pipeline, and a feed that stays fast | storage, queue, Drizzle, auth |
## What makes these different from a tutorial Each one has a section on the thing that is actually difficult, and each is honest about where the design stops working. The Trello chapter measures how many drags it takes before floating-point positions collapse — 42 — and then fixes it. The chat chapter explains why offset pagination breaks on a feed that people are writing to. The YouTube chapter has a video that is uploaded but not yet watchable, because that state is real and pretending it is not produces a broken player. ## What they share All three assume the SQLite default and say what changes for Postgres. All three use [`auth-basic`](https://theoven.app/docs/bricks/auth-basic/) — the [Google sign-in recipe](https://theoven.app/docs/recipes/google-login/) drops straight in if you want it. Every schema and every query on these pages was executed before it was written. Where a page quotes a result, that is the real output. ## Where to start New to Oven, start with [the tutorial](https://theoven.app/docs/tutorial/first-route/) — these assume you know what a route file and a brick are. Otherwise pick by what you want to learn: **chat** for real-time and pagination, **Trello** for permissions and ordering, **YouTube** for background work and file handling. --- ## Recipes Source: https://theoven.app/docs/recipes/ > Short, self-contained builds — sign in with Google, upload images, share files, go real-time. The [tutorial](https://theoven.app/docs/tutorial/first-route/) teaches one idea per chapter. [Build a todo API](https://theoven.app/docs/build/) builds one application end to end. These are the third thing: **a feature you drop into an application you already have**, finished, in one sitting. Each one is complete code, not fragments, and each says what it does *not* handle. | | | Uses | | --- | --- | --- | | [Sign in with Google](https://theoven.app/docs/recipes/google-login/) | 20 min | `auth`, `auth-basic` | | [Image uploads](https://theoven.app/docs/recipes/image-uploads/) | 20 min | `storage`, validation | | [Share a file with a link](https://theoven.app/docs/recipes/file-sharing/) | 30 min | `storage`, `queue`, `db` | | [Real-time notifications](https://theoven.app/docs/recipes/realtime/) | 25 min | core WebSockets, `auth` |
## What they assume An app that boots — the [installation guide](https://theoven.app/docs/start/installation/) or `bun create theoven my-app` gets you there. Where a recipe needs a database it assumes the SQLite default, and says what changes for Postgres. ## The code runs Every snippet is copied from something that was built and executed while the page was written, including the failures each recipe claims to produce. If one does not work for you, it is a bug in Oven or in these instructions. --- ## 1. Set it up Source: https://theoven.app/docs/build/setup/ > One command, and a tour of what you get before writing a line. ```bash bun create theoven todo-api --db sqlite --auth basic cd todo-api bun install ``` 1. **Set a signing secret.** The scaffold refuses to boot without one, on purpose — a framework that invents a secret has invented one every deployment shares. ```bash cp .env.example .env ``` Open `.env` and put something in `AUTH_SECRET`: ```bash # macOS / Linux openssl rand -base64 32 ``` 2. **Create the database.** ```bash bun run db:generate # writes a migration from your schema bun run db:migrate # applies it ``` 3. **Run it.** ```bash bun run dev ``` You now have a working API at `http://localhost:3000`, with browsable documentation at [`/docs`](http://localhost:3000/docs). ```bash curl -X POST localhost:3000/auth/signup \ -H 'content-type: application/json' \ -d '{"name":"Ada","email":"ada@example.com","password":"correct-horse"}' ``` That returns a user and an access token. You have not written any code. ## What you got ``` src/ app.ts builds the app — every brick registered here index.ts starts the server env.ts configuration, read and validated once at boot db.ts the database brick client.ts the connection, shared auth.ts the auth provider mail.ts the mail driver schema.ts your tables routes/ the route table — the filesystem *is* the router ``` ### `app.ts` is the whole configuration ```ts title="src/app.ts" export const app = createApp({ logLevel: config.logLevel }) .use(requestLogger()) .use(securityHeaders()) .use(database) .use(mail(driver)) .use(auth(provider)) .use(openapi({ info: { title: 'todo-api', version: '0.1.0' } })) await loadRoutes(app, `${import.meta.dir}/routes`) ``` Six lines, and each one is a feature. `.use(auth(provider))` is what mounted those eight `/auth/*` endpoints. `.use(database)` is what will put a typed client on every request. Leave one out and the thing it provides becomes a **compile error** wherever you use it — not a crash at 3am. ### `index.ts` is separate on purpose ```ts title="src/index.ts" await app.listen(config.port) ``` `app.ts` builds and exports; `index.ts` listens. That split is what lets `oven routes`, `oven openapi` and your tests import the app without binding a port. Keep it. ### One connection, shared ```ts title="src/client.ts" export const sqlite = new Database(config.databaseUrl) export const client = drizzle(sqlite, { schema }) ``` ```ts title="src/db.ts" export const database = db(drizzleSqlite({ client: sqlite, schema })) ``` `auth-basic` builds its user store at construction — before an app exists — so it needs a client of its own. The database brick **adopts** that same connection rather than opening a second. Two connections to a file merely waste a handle. Two connections to `:memory:` are two separate databases — and the symptom is `no such table` on a schema you can watch being created. You will want `:memory:` in [chapter 5](https://theoven.app/docs/build/testing/), so it is worth knowing why this line reads the way it does. ### `env.ts` fails loudly ```ts title="src/env.ts" function read() { return { port: env.port('PORT', 3000), databaseUrl: env.string('DATABASE_URL', './data.db'), authSecret: env.string('AUTH_SECRET'), } } ``` `env.string('AUTH_SECRET')` has no default, so a missing one fails at **startup** with a message naming the variable — rather than at 3am as `undefined` somewhere unrelated. ## Two things to try before moving on **The API reference** at [`/docs`](http://localhost:3000/docs) is generated from your routes. It already lists every auth endpoint. As you add schemas in the next chapter, it fills in. **The mail inbox** at [`/_oven/mail`](http://localhost:3000/_oven/mail). Try a password reset: ```bash curl -X POST localhost:3000/auth/forgot-password \ -H 'content-type: application/json' \ -d '{"email":"ada@example.com"}' ``` The reset link appears in that inbox and in your terminal, because mail defaults to the console driver. The whole flow works before you have signed up for a mail provider. ## Clear the decks The scaffold ships an example `users` resource to show the shape. You are building todos, so delete it: ```bash rm -rf src/routes/users src/routes/health.get.ts ``` [Next: the first endpoint →](https://theoven.app/docs/build/first-endpoint/) --- ## Chat: what you will build Source: https://theoven.app/docs/projects/chat/ > A chat backend with rooms, membership, message history and live delivery. A chat backend: rooms people join, message history that pages backwards, and live delivery over a socket that refuses anyone who is not a member. By the end you will have: - `POST /rooms`, `POST /rooms/:id/join`, and a membership check every room route shares - `GET /rooms/:id/messages` with **cursor pagination** that does not break while people are typing - `POST /rooms/:id/messages`, delivered to everyone connected in under a millisecond - `GET /rooms/:id/ws`, guarded by the same auth as everything else *and* by room membership ## The two things worth reading **Cursor pagination, not offset.** `LIMIT 20 OFFSET 40` is wrong on a feed people are writing to: between page one and page two, three new messages arrive, everything shifts, and the reader sees three they already saw. This is the single most common bug in a chat API and the fix is not difficult — it just has to be chosen deliberately. **The socket checks membership before it opens.** `auth: true` proves who someone is. It does not prove they belong in room `4f2a`. The check happens in `upgrade`, which is the last moment before a socket exists. The pagination on chapter 3 is real output from a table of 25 messages, not an illustration. ## The shape ``` src/ schema.ts rooms, members, messages app.ts bricks, and the socket route rooms.ts requireMember — the check every route shares routes/rooms/ index.post.ts create a room [id]/join.post.ts join one [id]/messages.get.ts history, paged [id]/messages.post.ts send ``` Four route files, a schema, and one shared function. Auth, validation, error shapes, request parsing and graceful shutdown you get without writing them. ## Before you start ```bash bun create theoven chat --db sqlite --auth basic cd chat && bun install ``` That gives you a working app with signup, login and password reset already mounted, which is what the rest of this assumes. [Set up the schema →](https://theoven.app/docs/projects/chat/schema/) --- ## Sign in with Google Source: https://theoven.app/docs/recipes/google-login/ > A working Google sign-in, from an empty app to a signed-in user, including the console setup everyone gets wrong. By the end you will have `GET /auth/oauth/google`, a callback that creates or finds the user, and a protected route that knows who they are. About twenty minutes, most of it in Google's console. 1. ### Install ```bash bun add @theoven/auth @theoven/auth-basic drizzle-orm ``` 2. ### Get credentials from Google In the [Google Cloud console](https://console.cloud.google.com/apis/credentials): **Create credentials → OAuth client ID → Web application**. Add one **Authorised redirect URI**, and it must match exactly what your app will send — scheme, host, port, path, no trailing slash: ``` http://localhost:3000/auth/oauth/google/callback ``` `redirect_uri_mismatch` is the error you will get, and it means the URI Google has and the one Oven sent are not byte-identical. `http` vs `https`, `localhost` vs `127.0.0.1`, and a trailing slash are all mismatches. Add your production URL as a second entry now, rather than discovering this at deploy time. Copy the client ID and secret into `.env`: ```bash title=".env" GOOGLE_CLIENT_ID=...apps.googleusercontent.com GOOGLE_CLIENT_SECRET=... APP_URL=http://localhost:3000 AUTH_SECRET=a-long-random-string COOKIE_SECRET=another-long-random-string ``` 3. ### Add the accounts table Linked provider accounts live in their own table, on a separate export path — so an app that never uses social sign-in never gets it: ```ts title="src/schema.ts" ins={2} export * from '@theoven/auth-basic/schema' export * from '@theoven/auth-basic/schema/accounts' ``` ```bash bun run db:generate && bun run db:migrate ``` 4. ### Configure the provider ```ts title="src/app.ts" import { createApp, env } from '@theoven/core' import { auth, google } from '@theoven/auth' import { basicAuth } from '@theoven/auth-basic' import { db } from '@theoven/db' import { drizzleSqlite } from '@theoven/db-drizzle' import { Database } from 'bun:sqlite' import { drizzle } from 'drizzle-orm/bun-sqlite' import * as schema from './schema' const sqlite = new Database('./data.db') const client = drizzle(sqlite, { schema }) export const app = createApp({ cookies: { secret: env.string('COOKIE_SECRET') } }) .use(db(drizzleSqlite({ client: sqlite, schema }))) .use( auth( basicAuth({ db: client, secret: env.string('AUTH_SECRET'), callbackUrl: (provider) => `${env.string('APP_URL')}/auth/oauth/${provider}/callback`, oauth: { google: { provider: google, clientId: env.string('GOOGLE_CLIENT_ID'), clientSecret: env.string('GOOGLE_CLIENT_SECRET'), }, }, }), ), ) ``` `cookies.secret` is not optional here — the sign-in handshake is carried in a **signed** cookie, and signing without a secret throws rather than quietly not signing. 5. ### Try it ```bash bun run dev open http://localhost:3000/auth/oauth/google ``` You land back on the callback with a user and an access token: ```json { "user": { "id": "0f8e…", "email": "ada@example.com", "name": "Ada" }, "accessToken": "eyJhbGciOi…", "expiresIn": 900, "created": true } ``` 6. ### Use it on a route ```ts title="src/routes/me.get.ts" export const auth = true export default async ({ user }) => ({ id: user.id, email: user.email, name: user.name }) ``` ```bash curl localhost:3000/me -H "Authorization: Bearer " ``` ## Sending the browser somewhere afterwards Returning JSON is right for an API. A web app wants a redirect: ```ts basicAuth({ // … afterOAuth: '/dashboard', }) ``` Or per sign-in, which is how you send someone back to the page they were on: ``` /auth/oauth/google?redirect=/settings/billing ``` The refresh token is set as an httpOnly cookie either way, so the browser can call `POST /auth/refresh` for a new access token without your JavaScript ever holding it. ## What just happened | | | | --- | --- | | A first sign-in | created the user, linked the Google account, issued a session | | A returning sign-in | found them by the Google account id — not the email | | Their password | an unusable sentinel; they have none, and `change-password` answers `409` | | The session | the same access JWT a password login issues, so `logout` and sign-out-everywhere work | ## Adding GitHub too ```ts const providers = { google: { provider: google, clientId: env.string('GOOGLE_CLIENT_ID'), clientSecret: env.string('GOOGLE_CLIENT_SECRET'), }, github: { provider: github, clientId: env.string('GITHUB_CLIENT_ID'), clientSecret: env.string('GITHUB_CLIENT_SECRET'), }, } ``` GitHub's callback URL is set under **Settings → Developer settings → OAuth Apps**. Its default scopes include `user:email`, which is required: GitHub omits the address from the profile whenever the user has kept it private, and without that scope the sign-in fails rather than creating an account with no email. ## Keeping password sign-in, or not Both work side by side by default — someone can sign up with a password *and* attach Google, and they are one user. For a Google-only application, turn the password endpoints off: ```ts basicAuth({ db: client, secret, password: false, callbackUrl, oauth: { google: googleConfig } }) ``` That mounts no `signup`, `login`, `forgot-password`, `reset-password` or `change-password`. `refresh`, `logout` and `me` stay — a session is a session however it began. On an app that meant to be Google-only, a live `/auth/signup` quietly accepts passwords behind you. That is a way in nobody intended, which is why this is a switch rather than something you are expected to notice. ## What this does not do - **No "link Google" button for someone already signed in.** Linking happens as a side effect of signing in, so a password user has to sign out first. An endpoint for the settings-page case is not built yet. - **No Apple.** Its client secret is a JWT you sign yourself and its callback is a form POST; it needs its own work. - **Provider tokens are not stored**, so you cannot call Google's API on the user's behalf. Pass `storeTokens: true` on the provider if you need to — and only then, because stored tokens are a liability. - **Someone signing in with Google at an address that already has a password account** is linked only because Google says it verified the address. A provider that does not verify is refused, with a message telling them to sign in and link from settings. --- ## Trello: what you will build Source: https://theoven.app/docs/projects/trello/ > Boards, lists and cards — with permissions that are named, and ordering that survives drag-and-drop. A board backend: boards with members, lists inside them, cards inside those, and a drag-and-drop ordering that does not rewrite every row every time somebody moves a card two places up. By the end you will have: - Boards with roles — owner, editor, viewer — enforced by [named policies](https://theoven.app/docs/bricks/auth/#policies) rather than `if` statements scattered through handlers - Lists and cards, with a move endpoint that touches **one row** - An activity log, written in the same transaction as the change it records ## The hard part **Ordering.** A card list has an order that users rearrange constantly. Storing `position: 0, 1, 2, 3` means moving one card renumbers everything after it — a write per card, per drag, and two people dragging at once produce a list that is wrong for both. The fix is to store positions as gaps rather than counts, so a move is a single write. That works until it doesn't, and chapter 4 measures exactly when: **42 drags into the same gap** before double-precision floats can no longer produce a value between two neighbours. Then it fixes that too. That number is measured, not estimated. ## The shape ``` src/ schema.ts boards, board_members, lists, cards, activity boards.ts requireRole — the permission check policies.ts named policies, registered once routes/boards/ index.post.ts create [id]/lists.post.ts add a list [id]/cards.post.ts add a card cards/[id]/move.patch.ts the interesting one ``` ## Before you start ```bash bun create theoven boards --db sqlite --auth basic cd boards && bun install ``` [The schema →](https://theoven.app/docs/projects/trello/schema/) --- ## YouTube: what you will build Source: https://theoven.app/docs/projects/youtube/ > Channels, an upload pipeline, playlists, subscriptions and a feed — the whole backend. The largest of the three: a video platform backend. Accounts and channels, an upload that survives a large file, playlists, subscriptions, and a home feed. By the end you will have: - Sign-up with a password **or** Google, and a channel per account - An upload that stores the file and hands it to a background job, with a video that is honestly *not yet watchable* until that job finishes - Visibility — public, unlisted, private — enforced on every read path - Playlists, ordered, reusing the [positions from Trello](https://theoven.app/docs/projects/trello/ordering/) - Subscriptions, and a feed that is one query no matter how many channels someone follows ## The hard parts **A video has states, and pretending otherwise breaks the player.** Between "the upload finished" and "this can be watched" there is real work — and if the API returns `ready` the moment bytes land, every client shows a broken video. The status column is the first thing on the schema for a reason. **The feed must not be a query per channel.** Someone following 200 channels should cost one query, not 200. Chapter 6 builds it and shows the output. **Visibility is checked on every path, not just one.** A private video is reachable through the channel page, the playlist it sits in, the feed, and its own URL. Four places, one rule — so the rule lives in one function. ## The shape ``` src/ schema.ts channels, videos, playlists, playlist_items, subscriptions visibility.ts the one rule, used by four routes jobs.ts the processing job routes/ channels/… create, read, subscribe videos/… upload, watch, update playlists/… create, add, reorder feed.get.ts the subscription feed ``` ## Before you start ```bash bun create theoven tube --db sqlite --auth basic cd tube && bun install bun add @theoven/storage @theoven/queue ``` [Channels and accounts →](https://theoven.app/docs/projects/youtube/channels/) --- ## 2. The first endpoint Source: https://theoven.app/docs/build/first-endpoint/ > A route file, a schema, and why the filesystem is the router. ## The filesystem is the route table There is no router file to register anything in. A file's path *is* its URL: ``` src/routes/todos/index.get.ts → GET /todos src/routes/todos/index.post.ts → POST /todos src/routes/todos/[id].patch.ts → PATCH /todos/:id src/routes/todos/[id].delete.ts → DELETE /todos/:id ``` Those four files are the whole API you are about to build. Create the directory: ```bash mkdir -p src/routes/todos ``` ## Teach `ctx` about your bricks One three-line file, once per project: ```ts title="src/route.ts" export const route = routesFor() ``` A route file and `app.ts` are separate modules, and TypeScript cannot relate them — so without this, `ctx.db` in a route would be `unknown`. `routesFor` binds the context to what your app actually registered. `import type` is erased at build time, so this does not create a cycle with the module that loads your routes. It is a type-only reference. ## Your first route ```ts title="src/routes/todos/index.get.ts" export default route( { summary: 'List your todos', tags: ['todos'], query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), }), }, (ctx) => ctx.db.select().from(todos).orderBy(desc(todos.createdAt)).limit(ctx.query.limit), ) ``` That does not compile yet — `todos` does not exist until the next chapter — but it is worth reading now, because four separate things are happening in nine lines. ### `route(schema, handler)` The schema and the handler are declared **together**. That pairing is what makes `ctx.query` typed: TypeScript cannot connect an `export const query` to a separate `export default`, so Oven does not ask it to. ### The query is validated and coerced `?limit=50` arrives as the string `"50"`. `z.coerce.number()` turns it into `50`, `.max(100)` refuses `?limit=99999` with a `422` before your code runs, and `.default(20)` means `ctx.query.limit` is always a number — never `undefined`. Oven does **not** coerce automatically. It cannot know what you meant: turning `"123"` into `123` would quietly break a `z.string()` field that legitimately holds digits, like an order reference. ### You return a value No `res.json()`, no `send()`. Return an array and you get `200` with `content-type: application/json`. Return `null` and you get `204`. Set `ctx.status` when you want something else. ### `ctx.db` is Drizzle Not a wrapper, not a repository. `ctx.db.select().from(todos)` is a Drizzle query — its autocomplete is Drizzle's, its errors are Drizzle's, and its documentation is Drizzle's. There is no Oven query language to learn, which is also why a coding model already knows how to write these. ## `summary` and `tags` are not decoration They are what makes [`/docs`](http://localhost:3000/docs) readable. The same declaration that validates the request describes the endpoint — so the documentation cannot drift from the behaviour, because there is only one of them. ## Check what you registered ```bash bun run routes ``` ``` ▲ Oven routes GET / GET /me GET /todos POST /auth/signup … ``` This imports `app.ts` without binding a port — which is exactly why `listen()` lives in `index.ts`. [Next: storing todos →](https://theoven.app/docs/build/storing-todos/) --- ## Chat: the schema Source: https://theoven.app/docs/projects/chat/schema/ > Three tables, and the two indexes that decide whether history stays fast. ```ts title="src/schema.ts" export * from '@theoven/auth-basic/schema' export const rooms = sqliteTable('rooms', { id: text('id').primaryKey(), name: text('name').notNull(), createdBy: text('created_by').notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }) export const members = sqliteTable( 'members', { roomId: text('room_id').notNull(), userId: text('user_id').notNull(), role: text('role', { enum: ['owner', 'member'] }) .notNull() .default('member'), joinedAt: integer('joined_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }, // The pair *is* the identity: joining twice is not a second membership. (table) => [primaryKey({ columns: [table.roomId, table.userId] })], ) export const messages = sqliteTable( 'messages', { id: text('id').primaryKey(), roomId: text('room_id').notNull(), authorId: text('author_id').notNull(), body: text('body').notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }, // Every history query is "this room, newest first". Without this index it is a table scan // that gets slower every day the app is used. (table) => [index('messages_room_idx').on(table.roomId, table.createdAt)], ) ``` ```bash bun run db:generate && bun run db:migrate ``` ## The three decisions in there ### Membership is a composite primary key, not an id `primaryKey({ columns: [roomId, userId] })` means the database refuses a second membership row for the same person in the same room. Joining twice is idempotent because it cannot be anything else — no `SELECT` first, no race between two clicks. An `id` column with a unique index would do the same thing and carry a value nobody reads. ### The index is on `(roomId, createdAt)`, in that order Every history query filters by room and sorts by time. A composite index in that order serves both in one pass. Reversed — `(createdAt, roomId)` — it serves neither well, because the filter is on the second column. Message tables grow without bound and history is the most-called endpoint in a chat app. It is worth being deliberate here even though the app is empty today. ### Timestamps are millisecond integers `{ mode: 'timestamp_ms' }` stores an integer and hands you a `Date`. SQLite has no date type, so the alternative is a string — which sorts correctly only by luck of format, and compares slowly. Milliseconds rather than seconds because two messages in the same second is normal, and the cursor in the next chapter orders by this column. ## Postgres Change three imports and nothing else: ```ts export const messages = pgTable('messages', { id: text('id').primaryKey(), // … createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }) ``` The queries in the following chapters are identical on both. [Rooms and membership →](https://theoven.app/docs/projects/chat/rooms/) --- ## Image uploads Source: https://theoven.app/docs/recipes/image-uploads/ > Accept an avatar, validate it before your code runs, store it, and serve it back. An upload endpoint that rejects anything too large or the wrong type **before your handler runs**, stores the file, and hands back a URL. Twenty minutes, and no `multer`. 1. ### Install ```bash bun add @theoven/storage ``` 2. ### Pick a driver ```ts title="src/app.ts" import { createApp, env } from '@theoven/core' import { diskStorage, s3Storage, storage } from '@theoven/storage' export const app = createApp().use( storage( env.has('S3_BUCKET') ? s3Storage({ bucket: env.string('S3_BUCKET') }) : diskStorage({ dir: './uploads' }), ), ) ``` Disk in development, S3 in production, and the same `ctx.storage` either way. Uploads on a container filesystem vanish on the next deploy, and that failure looks like nothing at all until someone asks where their avatar went. Configure S3 — or pass `allowDiskInProduction` if a disk that resets is genuinely what you want. 3. ### The endpoint ```ts title="src/routes/avatar.post.ts" import { z } from 'zod' export const auth = true export const body = z.object({ file: z.file().max(2_000_000).mime(['image/png', 'image/jpeg', 'image/webp']), }) export default async ({ body, storage, user }) => { const object = await storage.upload(`avatars/${user.id}`, body.file) return { key: object.key, size: object.size, type: object.type } } ``` That is the whole thing. The file arrives as a web `File` — core parses multipart bodies already — and the schema rejects an oversize or wrong-typed upload with a `422` **before** the handler runs, so your code never sees it. 4. ### Try it ```bash curl -X POST localhost:3000/avatar \ -H "Authorization: Bearer $TOKEN" \ -F file=@portrait.png ``` ```json { "key": "avatars/0f8e…", "size": 20481, "type": "image/png" } ``` And the failure, which is the part worth checking: ```bash curl -X POST localhost:3000/avatar -H "Authorization: Bearer $TOKEN" -F file=@huge.png # 422 — the handler never ran ``` 5. ### Serve it back ```ts title="src/routes/avatar/[id].get.ts" import { z } from 'zod' export const params = z.object({ id: z.uuid() }) export default async ({ params, storage }) => storage.download(`avatars/${params.id}`) ``` `download()` returns a lazy `Blob` — nothing is fetched until the response is written, and returning it streams rather than buffering. ## Keying by something stable `avatars/${user.id}` overwrites the previous avatar, which is usually what you want. If you need history, or cache-friendly URLs that never go stale, key by content instead: ```ts const key = `avatars/${user.id}/${Bun.hash(await body.file.arrayBuffer())}.png` ``` Then the URL changes when the image does, and you can cache it forever. ## Letting the browser upload straight to S3 For large files, sending them through your server twice is wasted bandwidth. With S3, hand the browser a URL to `PUT` to: ```ts title="src/routes/avatar/presign.post.ts" export const auth = true export default async ({ storage, user }) => ({ url: storage.presignUpload(`avatars/${user.id}`, { expiresIn: 300 }), }) ``` `storage.canPresign` is `true` on S3 and **`false` on disk** — the local driver has no signed URLs, and Bunny and ImageKit can sign downloads but not uploads. Branch on it rather than assuming, or the feature works in development and 500s in production. The trade is that your server never sees the bytes, so the size and type limits in the schema no longer apply — set them on the bucket policy instead. ## Resizing Storage stores. If you want thumbnails, either generate them yourself in a [queue job](https://theoven.app/docs/bricks/queue/), or use a driver that resizes on delivery: ```ts const media = imagekitStorage({ privateKey, urlEndpoint }) media.url('avatars/0f8e', { transform: { width: 200, height: 200, format: 'auto' } }) ``` Upload once, serve any size from the URL, and store no thumbnails. ## What this does not do - **No image inspection.** `.mime()` checks the declared content type, not the bytes. Someone can label anything `image/png`. If that matters — and it does if you ever render it inline — verify the magic bytes, or serve from a separate origin with `Content-Disposition: attachment`. - **No virus scanning.** - **No cleanup.** Overwriting an avatar leaves the old object if you keyed by hash. A [queue job](https://theoven.app/docs/bricks/queue/) is the place for that. - **Large uploads spill to a temp file** rather than being held in memory, but the request still passes through your server unless you presign. --- ## Trello: schema and permissions Source: https://theoven.app/docs/projects/trello/schema/ > Five tables, three roles, and permissions that have names instead of being scattered through handlers. ```ts title="src/schema.ts" export * from '@theoven/auth-basic/schema' export const boards = sqliteTable('boards', { id: text('id').primaryKey(), title: text('title').notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }) export const boardMembers = sqliteTable( 'board_members', { boardId: text('board_id').notNull(), userId: text('user_id').notNull(), role: text('role', { enum: ['owner', 'editor', 'viewer'] }).notNull(), }, (table) => [primaryKey({ columns: [table.boardId, table.userId] })], ) export const lists = sqliteTable( 'lists', { id: text('id').primaryKey(), boardId: text('board_id').notNull(), title: text('title').notNull(), position: real('position').notNull(), }, (table) => [index('lists_board_idx').on(table.boardId, table.position)], ) export const cards = sqliteTable( 'cards', { id: text('id').primaryKey(), listId: text('list_id').notNull(), title: text('title').notNull(), description: text('description'), // A float, not an integer — see chapter 4. This is the whole ordering design. position: real('position').notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }, (table) => [index('cards_list_idx').on(table.listId, table.position)], ) export const activity = sqliteTable( 'activity', { id: text('id').primaryKey(), boardId: text('board_id').notNull(), actorId: text('actor_id').notNull(), action: text('action').notNull(), detail: text('detail', { mode: 'json' }).$type>(), at: integer('at', { mode: 'timestamp_ms' }).notNull().default(sql`(unixepoch() * 1000)`), }, (table) => [index('activity_board_idx').on(table.boardId, table.at)], ) ``` ## Roles, once Three roles, ordered by what they can do: ```ts title="src/boards.ts" const RANK = { viewer: 0, editor: 1, owner: 2 } as const export type Role = keyof typeof RANK /** Refuses anyone below `needed`, and returns their role when they are at or above it. */ export async function requireRole(db, boardId: string, userId: string, needed: Role) { const [membership] = await db .select() .from(boardMembers) .where(and(eq(boardMembers.boardId, boardId), eq(boardMembers.userId, userId))) // Not a member: the board's existence is not theirs to learn, so 404 rather than 403. if (!membership) throw new NotFound('No such board.') if (RANK[membership.role] < RANK[needed]) { throw new Forbidden(`This needs ${needed} access; you have ${membership.role}.`) } return membership.role } ``` The difference is deliberate. Someone who is not on the board should not learn that a board with that id exists — that leaks which ids are real. Someone who *is* on it already knows, so telling them they need editor access is helpful rather than revealing. ## Naming the permissions Oven does not normalise roles onto `ctx.user`, because most providers have no such concept (D17). Authorization is a named function instead: ```ts title="src/policies.ts" export const policies = { /** Signed in and on at least one board. Cheap gate for the board list. */ member: async (user, ctx) => Boolean(user.id), } ``` Board-level roles are per-board, so they cannot live in a policy — a policy sees the user and the context, not which board this request is about. That is what `requireRole` is for, and the split is worth stating: **policies answer "who is this", `requireRole` answers "what may they do here".** ## Boards ```ts title="src/routes/boards/index.post.ts" export const auth = true export const body = z.object({ title: z.string().min(1).max(120) }) export default async ({ body, db, user }) => { const id = crypto.randomUUID() await transaction(db, async (tx) => { await tx.insert(boards).values({ id, title: body.title }) await tx.insert(boardMembers).values({ boardId: id, userId: user.id, role: 'owner' }) }) return { id, title: body.title, role: 'owner' } } ``` ```ts title="src/routes/boards/[id]/members.post.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const body = z.object({ userId: z.uuid(), role: z.enum(['editor', 'viewer']) }) export default async ({ body, db, params, user }) => { // Only an owner adds people — and note `role` cannot be 'owner', so this endpoint // cannot be used to promote someone past the person calling it. await requireRole(db, params.id, user.id, 'owner') await db .insert(boardMembers) .values({ boardId: params.id, userId: body.userId, role: body.role }) .onConflictDoUpdate({ target: [boardMembers.boardId, boardMembers.userId], set: { role: body.role }, }) return { added: true } } ``` The enum excluding `owner` is doing real work: it makes privilege escalation through this endpoint impossible at the schema level, before the handler runs, rather than relying on a check somebody might remove. [Lists and cards →](https://theoven.app/docs/projects/trello/cards/) --- ## YouTube: channels and accounts Source: https://theoven.app/docs/projects/youtube/channels/ > A channel per account, handles that cannot collide, and Google sign-in dropped in. ## The schema so far ```ts title="src/schema.ts" export * from '@theoven/auth-basic/schema' export * from '@theoven/auth-basic/schema/accounts' export const channels = sqliteTable( 'channels', { id: text('id').primaryKey(), ownerId: text('owner_id').notNull(), /** The @handle. Lowercased on write; the index is what actually enforces uniqueness. */ handle: text('handle').notNull(), name: text('name').notNull(), description: text('description'), avatarKey: text('avatar_key'), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }, (table) => [ uniqueIndex('channels_handle_idx').on(table.handle), index('channels_owner_idx').on(table.ownerId), ], ) ``` The accounts export is there because this app offers Google sign-in — the [recipe](https://theoven.app/docs/recipes/google-login/) is the setup, and it changes nothing else on this page. ## Creating a channel ```ts title="src/routes/channels/index.post.ts" export const auth = true export const body = z.object({ // Handles are a namespace: constrain the characters, or you get `@ada`, `@Ada` and `@a.d.a` // all meaning different channels and nobody able to type any of them. handle: z .string() .min(3) .max(30) .regex(/^[a-z0-9_]+$/, 'Lowercase letters, numbers and underscores only.'), name: z.string().min(1).max(80), }) export default async ({ body, db, user }) => { const [existing] = await db.select().from(channels).where(eq(channels.ownerId, user.id)) if (existing) throw new Conflict('You already have a channel.') try { const [channel] = await db .insert(channels) .values({ id: crypto.randomUUID(), ownerId: user.id, handle: body.handle, name: body.name }) .returning() return channel } catch { // The unique index is what actually decides. Checking first and inserting second has a // window between them, and two people claiming `@ada` at once would both pass the check. throw new Conflict(`@${body.handle} is taken.`) } } ``` A `SELECT` before the `INSERT` is a race: both requests see the handle free, both insert, one gets a constraint error anyway. Catching the constraint is not a shortcut — it is the only place the answer is actually decided. The lookup for "you already have a channel" is different: it is not a uniqueness rule, and the worst case is a rare duplicate that a background check can clean up. ## Reading one ```ts title="src/routes/channels/[handle].get.ts" export const params = z.object({ handle: z.string() }) export default async ({ db, params }) => { const [channel] = await db .select() .from(channels) .where(eq(channels.handle, params.handle.toLowerCase())) if (!channel) throw new NotFound('No such channel.') const [{ subscribers }] = await db .select({ subscribers: count() }) .from(subscriptions) .where(eq(subscriptions.channelId, channel.id)) return { ...channel, subscribers } } ``` No `auth` export — channel pages are public. Note the subscriber count is computed rather than stored; chapter 6 covers when that stops being the right call. ## Avatars Straight from the [uploads recipe](https://theoven.app/docs/recipes/image-uploads/): ```ts title="src/routes/channels/avatar.post.ts" export const auth = true export const body = z.object({ file: z.file().max(2_000_000).mime(['image/png', 'image/jpeg', 'image/webp']), }) export default async ({ body, db, storage, user }) => { const [channel] = await db.select().from(channels).where(eq(channels.ownerId, user.id)) if (!channel) throw new NotFound('Create a channel first.') const key = `avatars/${channel.id}` await storage.upload(key, body.file) await db.update(channels).set({ avatarKey: key }).where(eq(channels.id, channel.id)) return { avatarKey: key } } ``` Keyed by channel id, so a new avatar replaces the old one and there is nothing to clean up. Videos in the next chapter are keyed differently, and the reason is worth noticing when you get there. [Uploading a video →](https://theoven.app/docs/projects/youtube/uploading/) --- ## 3. Storing todos Source: https://theoven.app/docs/build/storing-todos/ > A table, a migration, and the three routes that write to it. ## The table ```ts title="src/schema.ts" export const todos = sqliteTable('todos', { id: text('id').primaryKey(), userId: text('user_id').notNull(), title: text('title').notNull(), notes: text('notes'), done: integer('done', { mode: 'boolean' }).notNull().default(false), dueAt: integer('due_at', { mode: 'timestamp_ms' }), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), }) export * from '@theoven/auth-basic/schema' ``` Two things worth pausing on. **`userId` is there from the start.** Every todo belongs to someone. Adding ownership later means a migration and an audit of every query; putting it in now costs one line. **That last line re-exports auth's three tables** — `auth_users`, `auth_refresh_tokens`, `auth_reset_tokens` — so `oven db generate` writes migrations for them alongside yours. Not at compile time — at runtime, with `no such table: auth_users`, on the first person who tries to sign up. drizzle-kit only sees what `src/schema.ts` exports. `mode: 'boolean'` and `mode: 'timestamp_ms'` are Drizzle telling SQLite how to store types it does not have. You get real `boolean` and `Date` values in your handlers. ## Migrate ```bash bun run db:generate bun run db:migrate ``` ``` 4 tables todos 7 columns 0 indexes 0 fks auth_refresh_tokens 5 columns 2 indexes 1 fks auth_reset_tokens 5 columns 1 indexes 1 fks auth_users 6 columns 1 indexes 0 fks ``` `db:generate` writes a SQL file into `drizzle/` — **commit it**. It is the record of how your schema got to where it is, and it is what `db:migrate` replays on a fresh database. ## Creating ```ts title="src/routes/todos/index.post.ts" export default route( { summary: 'Create a todo', tags: ['todos'], body: z.object({ title: z.string().min(1).max(200), notes: z.string().max(2000).optional(), dueAt: z.coerce.date().optional(), }), }, async (ctx) => { const [todo] = await ctx.db .insert(todos) .values({ id: crypto.randomUUID(), userId: 'anonymous', // fixed in the next chapter title: ctx.body.title, notes: ctx.body.notes ?? null, dueAt: ctx.body.dueAt ?? null, createdAt: new Date(), }) .returning() ctx.status = 201 return todo }, ) ``` `z.coerce.date()` accepts `"2026-09-01"` from JSON and hands your handler a real `Date`. There is no `if (!title) return res.status(400)` because a request without a title never reaches this function. ## Updating ```ts title="src/routes/todos/[id].patch.ts" export default route( { summary: 'Update a todo', tags: ['todos'], params: z.object({ id: z.uuid() }), body: z .object({ title: z.string().min(1).max(200).optional(), notes: z.string().max(2000).nullable().optional(), done: z.boolean().optional(), dueAt: z.coerce.date().nullable().optional(), }) .refine((patch) => Object.keys(patch).length > 0, 'Send at least one field to change.'), }, async (ctx) => { const [updated] = await ctx.db .update(todos) .set(ctx.body) .where(eq(todos.id, ctx.params.id)) .returning() if (!updated) throw new NotFound(`No todo with id ${ctx.params.id}.`) return updated }, ) ``` Three details: **`z.uuid()` on the param** means a request to `/todos/haha-not-a-uuid` is refused with a `422` before any query runs. Your database never sees it. **`.refine()` rejects an empty patch.** `PATCH` with `{}` is almost always a bug in the caller, and silently returning the unchanged row hides it. **`throw new NotFound(...)`**, not `return res.status(404)`. Thrown errors become [RFC 9457](https://theoven.app/docs/reference/errors/) problem documents, with a request id, whether they were thrown synchronously or five `await`s deep. ## Deleting ```ts title="src/routes/todos/[id].delete.ts" export default route( { summary: 'Delete a todo', tags: ['todos'], params: z.object({ id: z.uuid() }), }, async (ctx) => { const [deleted] = await ctx.db .delete(todos) .where(eq(todos.id, ctx.params.id)) .returning() if (!deleted) throw new NotFound(`No todo with id ${ctx.params.id}.`) ctx.status = 204 return null }, ) ``` `.returning()` is doing real work here: it tells you whether anything was actually deleted, so a second `DELETE` on the same id is a `404` rather than a cheerful `204` for a row that stopped existing minutes ago. ## Try it ```bash curl -X POST localhost:3000/todos \ -H 'content-type: application/json' \ -d '{"title":"Write the walkthrough","dueAt":"2026-09-01"}' ``` ```json { "id": "4550bbb1-e547-4b7c-8426-a3b66edee1e6", "userId": "anonymous", "title": "Write the walkthrough", "notes": null, "done": false, "dueAt": "2026-09-01T00:00:00.000Z", "createdAt": "2026-08-19T15:32:50.412Z" } ``` And the validation: ```bash curl -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":""}' ``` ```json { "type": "about:blank", "title": "Unprocessable Content", "status": 422, "detail": "Request validation failed.", "errors": [ { "location": "body", "path": "title", "message": "Too small: expected string to have >=1 characters" } ], "requestId": "9cb7b217-1d07-4a2e-9d4a-0b1c2d3e4f50" } ``` Which is the shape *every* error in your API has, without you designing one. Every todo has `userId: 'anonymous'` and no query filters on it. That is the next chapter, and it is the interesting one. [Next: locking it down →](https://theoven.app/docs/build/auth/) --- ## Chat: rooms and membership Source: https://theoven.app/docs/projects/chat/rooms/ > Create a room, join one, and write the check that every other route depends on. ## The check everything shares Every room route asks the same question, so it is written once: ```ts title="src/rooms.ts" /** * Refuses anyone who is not in the room, and returns their membership when they are. * * `Forbidden`, not `NotFound`: they are authenticated, so hiding the room's existence buys * nothing — and a 403 tells them to ask for an invite rather than to check their spelling. */ export async function requireMember(db, roomId: string, userId: string) { const [membership] = await db .select() .from(members) .where(and(eq(members.roomId, roomId), eq(members.userId, userId))) if (!membership) throw new Forbidden('You are not in this room.') return membership } ``` Verified against the real table: ``` member : ok outsider: You are not in this room. ``` ## Creating a room ```ts title="src/routes/rooms/index.post.ts" export const auth = true export const body = z.object({ name: z.string().min(1).max(80) }) export default async ({ body, db, user }) => { const id = crypto.randomUUID() // Both rows or neither: a room whose creator is not a member is a room nobody can enter, // including the person who just made it. await transaction(db, async (tx) => { await tx.insert(rooms).values({ id, name: body.name, createdBy: user.id }) await tx.insert(members).values({ roomId: id, userId: user.id, role: 'owner' }) }) return { id, name: body.name } } ``` On `bun:sqlite`, Drizzle's transaction commits before an async rejection arrives, so work that throws after an `await` is committed anyway. `transaction()` issues the boundaries itself. There is [a test in the framework](https://theoven.app/docs/bricks/db-drizzle/#transactions) that demonstrates the difference. ## Joining ```ts title="src/routes/rooms/[id]/join.post.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export default async ({ db, params, user }) => { const [room] = await db.select().from(rooms).where(eq(rooms.id, params.id)) if (!room) throw new NotFound('No such room.') // Joining twice is the same as joining once — the composite primary key says so, and // `onConflictDoNothing` is how you ask the database rather than checking first and racing. await db .insert(members) .values({ roomId: params.id, userId: user.id }) .onConflictDoNothing() return { joined: true } } ``` The double-click case is handled by the schema. A `SELECT` then `INSERT` would have a window between them, and two fast clicks would produce a constraint error the user did nothing to deserve. ## Listing the rooms someone is in ```ts title="src/routes/rooms/index.get.ts" export const auth = true export default async ({ db, user }) => db .select({ id: rooms.id, name: rooms.name, role: members.role }) .from(members) .innerJoin(rooms, eq(rooms.id, members.roomId)) .where(eq(members.userId, user.id)) ``` Driven from `members`, not `rooms` — the answer is "rooms this person is in", and starting from the table that knows that avoids filtering a growing table by a subquery. ## Leaving ```ts title="src/routes/rooms/[id]/leave.post.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export default async ({ db, params, user }) => { const membership = await requireMember(db, params.id, user.id) if (membership.role === 'owner') { const [{ owners }] = await db .select({ owners: count() }) .from(members) .where(and(eq(members.roomId, params.id), eq(members.role, 'owner'))) if (owners === 1) throw new Conflict('Promote another owner before leaving.') } await db.delete(members).where(and(eq(members.roomId, params.id), eq(members.userId, user.id))) return { left: true } } ``` The owner check is the kind of thing that is obvious in hindsight and absent from most first drafts: without it, the last owner leaves and the room can never be administered again. [Messages and pagination →](https://theoven.app/docs/projects/chat/messages/) --- ## Share a file with a link Source: https://theoven.app/docs/recipes/file-sharing/ > Upload a file, get a link that expires, and have the file clean itself up afterwards. Upload something, get back a link anyone can open until it expires, and let a background job delete what nobody needs any more. Storage, a table, and a cron job — about thirty minutes. 1. ### Install ```bash bun add @theoven/storage @theoven/queue ``` 2. ### A table for the shares The file lives in storage; this row is what makes a link resolvable and expirable. ```ts title="src/schema.ts" import { sql } from 'drizzle-orm' import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' export const shares = sqliteTable('shares', { id: text('id').primaryKey(), key: text('key').notNull(), filename: text('filename').notNull(), size: integer('size').notNull(), ownerId: text('owner_id').notNull(), expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }) ``` 3. ### Upload and create the link ```ts title="src/routes/shares/index.post.ts" import { z } from 'zod' import { shares } from '../../schema' export const auth = true export const body = z.object({ file: z.file().max(25_000_000), // Coerced, so `?days=3` from a form is a number by the time you read it. days: z.coerce.number().int().min(1).max(30).default(7), }) export default async ({ body, db, storage, user }) => { const id = crypto.randomUUID() const key = `shares/${id}/${body.file.name}` await storage.upload(key, body.file) const [share] = await db .insert(shares) .values({ id, key, filename: body.file.name, size: body.file.size, ownerId: user.id, expiresAt: new Date(Date.now() + body.days * 86_400_000), }) .returning() return { id: share.id, url: `/shares/${share.id}`, expiresAt: share.expiresAt } } ``` 4. ### Resolve it Public on purpose — the id is the secret — but expiry is checked on every read: ```ts title="src/routes/shares/[id].get.ts" import { eq } from 'drizzle-orm' import { NotFound } from '@theoven/core' import { z } from 'zod' import { shares } from '../../schema' export const params = z.object({ id: z.uuid() }) export default async (ctx) => { const [share] = await ctx.db.select().from(shares).where(eq(shares.id, ctx.params.id)) // The same answer for "never existed" and "expired": a different one tells a stranger // which ids were once real. if (!share || share.expiresAt < new Date()) throw new NotFound('No such file.') ctx.set('content-disposition', `attachment; filename="${share.filename}"`) return ctx.storage.download(share.key) } ``` 5. ### Delete what expired ```ts title="src/jobs.ts" import { defineJob } from '@theoven/queue' import { lt } from 'drizzle-orm' import { shares } from './schema' export const sweepShares = defineJob({ name: 'sweep-shares', handler: async ({ log }) => { const expired = await db.select().from(shares).where(lt(shares.expiresAt, new Date())) for (const share of expired) { // Storage first: a row without an object is a 404, an object without a row is a file // nobody can reach and nobody will ever delete. await storage.delete(share.key) await db.delete(shares).where(eq(shares.id, share.id)) } log.info('swept expired shares', { count: expired.length }) }, }) ``` ```ts title="src/app.ts" import { memoryQueue, queue } from '@theoven/queue' import { sweepShares } from './jobs' app.use( queue(memoryQueue(), { jobs: [sweepShares], cron: { nightly: { schedule: '0 3 * * *', job: sweepShares } }, }), ) ``` ## The ordering in the sweep is the interesting part Delete the row first and a crash leaves an object with no row — unreachable, and nothing will ever look for it again. Delete the object first and a crash leaves a row whose download 404s, which the next sweep fixes. One failure mode is self-healing and the other is a slow leak of storage you pay for. ## Skipping your server entirely With S3, hand out a presigned link and let the browser fetch from the bucket: ```ts if (!ctx.storage.canPresign) return ctx.storage.download(share.key) const remaining = Math.floor((share.expiresAt.getTime() - Date.now()) / 1000) return ctx.redirect(ctx.storage.presignDownload(share.key, { expiresIn: Math.min(remaining, 3600) })) ``` `canPresign` is `false` on the disk driver, so the branch is what keeps this working in development. Capping the signed URL at the share's own expiry matters: without the `min`, a link signed for an hour outlives a share that expired in ten minutes. ## Production Swap two drivers and change nothing else: ```ts app.use(storage(s3Storage({ bucket: env.string('S3_BUCKET') }))) app.use(queue(sqlQueue({ url: env.string('DATABASE_URL') }), { jobs: [sweepShares], cron })) ``` The memory queue refuses to boot in production anyway — a deploy would silently drop every pending sweep — so this is a change you will be told to make rather than one you can forget. ## What this does not do - **The id is the only secret.** Anyone with the link can download. That is the point of a share link, but it means no revocation beyond deleting the row, and no per-recipient access. - **No download limits or counts.** Both are a column and a check away. - **No virus scanning, and no content-type sniffing.** `Content-Disposition: attachment` is doing the work of making a hostile upload not render in a browser; serve from a separate origin if that matters. - **The sweep runs on one schedule.** A share that expires at noon is still downloadable until the 3am sweep unless the read path checks expiry — which is why the read path checks expiry. --- ## Trello: lists and cards Source: https://theoven.app/docs/projects/trello/cards/ > The straightforward half — creating lists and cards, and reading a board in one query. ## A list ```ts title="src/routes/boards/[id]/lists.post.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const body = z.object({ title: z.string().min(1).max(80) }) const STEP = 1024 export default async ({ body, db, params, user }) => { await requireRole(db, params.id, user.id, 'editor') // Append: one step past whatever is currently last. const [last] = await db .select({ position: lists.position }) .from(lists) .where(eq(lists.boardId, params.id)) .orderBy(desc(lists.position)) .limit(1) const [created] = await db .insert(lists) .values({ id: crypto.randomUUID(), boardId: params.id, title: body.title, position: (last?.position ?? 0) + STEP, }) .returning() return created } ``` `STEP = 1024` rather than `1` is the entire ordering design, and the next chapter is about why. ## A card ```ts title="src/routes/lists/[id]/cards.post.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const body = z.object({ title: z.string().min(1).max(200), description: z.string().max(10_000).optional(), }) export default async ({ body, db, params, user }) => { const [list] = await db.select().from(lists).where(eq(lists.id, params.id)) if (!list) throw new NotFound('No such list.') // The card's permission comes from the board the list belongs to. await requireRole(db, list.boardId, user.id, 'editor') const [last] = await db .select({ position: cards.position }) .from(cards) .where(eq(cards.listId, params.id)) .orderBy(desc(cards.position)) .limit(1) const [created] = await db .insert(cards) .values({ id: crypto.randomUUID(), listId: params.id, title: body.title, description: body.description ?? null, position: (last?.position ?? 0) + STEP, }) .returning() return created } ``` Permission is looked up through the list, not passed in. A `boardId` in the request body would be a client asserting its own authorisation, which is not a thing a client gets to do. ## Reading a whole board The screen needs the board, its lists, and every card, sorted. That is two queries, not one per list: ```ts title="src/routes/boards/[id].get.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export default async ({ db, params, user }) => { await requireRole(db, params.id, user.id, 'viewer') const [board] = await db.select().from(boards).where(eq(boards.id, params.id)) const boardLists = await db .select() .from(lists) .where(eq(lists.boardId, params.id)) .orderBy(asc(lists.position)) const boardCards = await db .select() .from(cards) .where( inArray( cards.listId, boardLists.map((list) => list.id), ), ) .orderBy(asc(cards.position)) // Grouped in memory: one pass over rows already fetched beats a query per list. const byList = new Map(boardLists.map((list) => [list.id, [] as typeof boardCards])) for (const card of boardCards) byList.get(card.listId)?.push(card) return { ...board, lists: boardLists.map((list) => ({ ...list, cards: byList.get(list.id) ?? [] })), } } ``` Two queries regardless of how many lists a board has. The version that loops over lists and queries each one is the N+1 that makes a board with twelve lists thirteen round trips — fine on a laptop with SQLite in-process, ruinous over a network to Postgres. One edge worth handling: `inArray` with an empty array. A brand-new board has no lists, and some drivers turn `IN ()` into a syntax error. Guard it: ```ts const boardCards = boardLists.length ? await db.select().from(cards).where(inArray(cards.listId, boardLists.map((l) => l.id))) : [] ``` [Ordering, and where it breaks →](https://theoven.app/docs/projects/trello/ordering/) --- ## YouTube: the upload pipeline Source: https://theoven.app/docs/projects/youtube/uploading/ > A video that is honestly not watchable yet, and a background job that makes it so. ## A video has states ```ts title="src/schema.ts" export const videos = sqliteTable( 'videos', { id: text('id').primaryKey(), channelId: text('channel_id').notNull(), title: text('title').notNull(), description: text('description'), /** * Where this video is in the pipeline. * * `ready` is the only state a viewer may see, and it is set by the processing job — never * by the upload endpoint. Returning `ready` when the bytes land means every client shows a * player pointed at something that cannot be played yet. */ status: text('status', { enum: ['uploading', 'processing', 'ready', 'failed'] }) .notNull() .default('uploading'), visibility: text('visibility', { enum: ['public', 'unlisted', 'private'] }) .notNull() .default('private'), sourceKey: text('source_key'), thumbnailKey: text('thumbnail_key'), durationSeconds: integer('duration_seconds'), views: integer('views').notNull().default(0), /** Null until it is both `ready` and made public. This is what the feed orders by. */ publishedAt: integer('published_at', { mode: 'timestamp_ms' }), }, (table) => [index('videos_channel_idx').on(table.channelId, table.publishedAt)], ) ``` A video whose visibility defaults to `public` is published the instant the row is written — before the owner has titled it, before processing finished, and before they meant to. Defaulting to private means publishing is something someone does, rather than something that happens to them. ## The upload ```ts title="src/routes/videos/index.post.ts" export const auth = true export const body = z.object({ file: z.file().max(2_000_000_000).mime(['video/mp4', 'video/quicktime', 'video/webm']), title: z.string().min(1).max(120), description: z.string().max(5000).optional(), }) export default async ({ body, db, queue, storage, user }) => { const [channel] = await db.select().from(channels).where(eq(channels.ownerId, user.id)) if (!channel) throw new NotFound('Create a channel first.') const id = crypto.randomUUID() // Keyed by video id, not by channel: a second upload must not overwrite the first, and the // key has to stay valid for as long as the row does. const sourceKey = `videos/${id}/source` await storage.upload(sourceKey, body.file) const [video] = await db .insert(videos) .values({ id, channelId: channel.id, title: body.title, description: body.description ?? null, sourceKey, status: 'processing', }) .returning() // Handed off. The request returns now; the work happens elsewhere. await queue.dispatch(processVideo, { videoId: id, sourceKey }) ctx.status = 202 return video } ``` `202 Accepted`, not `201 Created`. The row exists, the file is stored, and the thing the caller actually wants — a watchable video — has not happened yet. A `201` would be a lie the client believes. ## The job ```ts title="src/jobs.ts" export const processVideo = defineJob<{ videoId: string; sourceKey: string }>({ name: 'process-video', retries: 3, backoff: 5000, timeout: 30 * 60_000, handler: async ({ payload, signal, log }) => { const { videoId, sourceKey } = payload // Whatever your pipeline is — ffmpeg, a transcoding service, a thumbnail grab. const { durationSeconds, thumbnailKey } = await transcode(sourceKey, { signal }) await db .update(videos) .set({ status: 'ready', durationSeconds, thumbnailKey }) .where(eq(videos.id, videoId)) log.info('video ready', { videoId, durationSeconds }) }, }) ``` Three things worth noting. **`signal` is passed to the work.** A thirty-minute timeout means a stuck transcode is abandoned rather than holding a worker forever — but only if the work you call actually respects the signal. Passing it and ignoring it is the same as not having a timeout. **The retries are for transient failures.** A transcoder that is briefly unavailable should be retried; a file that is not really a video should not. Which is why the failure path is explicit: ```ts title="src/jobs.ts" handler: async ({ payload, remaining, log }) => { try { // … } catch (error) { // On the last attempt, record the failure so the owner sees something other than a // video stuck on "processing" forever. if (remaining === 0) { await db.update(videos).set({ status: 'failed' }).where(eq(videos.id, payload.videoId)) log.error('video failed permanently', { videoId: payload.videoId }) } throw error } } ``` Without that, a video that fails all three attempts sits at `processing` indefinitely and the owner has no idea why. A dead-lettered job is visible to *you*; `status: 'failed'` is visible to *them*. **The job does not publish the video.** It sets `ready`. Whether anyone can see it is visibility, which is the owner's decision and the next chapter's subject. ## Publishing ```ts title="src/routes/videos/[id].patch.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const body = z.object({ title: z.string().min(1).max(120).optional(), description: z.string().max(5000).optional(), visibility: z.enum(['public', 'unlisted', 'private']).optional(), }) export default async ({ body, db, params, user }) => { const video = await requireOwnedVideo(db, params.id, user.id) if (body.visibility === 'public' && video.status !== 'ready') { throw new Conflict('This video is still processing.') } const [updated] = await db .update(videos) .set({ ...body, // Set once, the first time it goes public — so a private/public toggle does not keep // moving it back to the top of everyone's feed. publishedAt: body.visibility === 'public' && !video.publishedAt ? new Date() : video.publishedAt, }) .where(eq(videos.id, params.id)) .returning() return updated } ``` The `publishedAt` rule is the sort of thing that only shows up in production: without it, unlisting and relisting a year-old video republishes it to every subscriber's feed. ## Large files The upload above passes bytes through your server. For real video, hand the browser a URL and let it upload straight to the bucket: ```ts title="src/routes/videos/upload-url.post.ts" export const auth = true export const body = z.object({ contentType: z.enum(['video/mp4', 'video/webm']) }) export default async ({ body, storage, user }) => { if (!storage.canPresign) throw new ServiceUnavailable('Direct upload is not configured.') const id = crypto.randomUUID() return { videoId: id, url: storage.presignUpload(`videos/${id}/source`, { expiresIn: 3600 }) } } ``` `canPresign` is `false` on the disk driver, so the check is what keeps this honest in development rather than 500ing in production. The client then calls a confirm endpoint to create the row and dispatch the job — the size and type limits move to the bucket policy, since your server no longer sees the bytes. [Watching, and visibility →](https://theoven.app/docs/projects/youtube/watching/) --- ## 4. Locking it down Source: https://theoven.app/docs/build/auth/ > Signup, login, and scoping every query to its owner — the chapter that matters. The auth endpoints have been running since chapter 1. You did not write them, and you are not going to. What this chapter does is connect them to your todos. ## What you already have ``` POST /auth/signup name, email, password POST /auth/login returns an access token, sets a refresh cookie POST /auth/refresh rotates both POST /auth/logout genuinely revokes the session GET /auth/me the signed-in user POST /auth/change-password signs every other session out POST /auth/forgot-password emails a single-use link POST /auth/reset-password redeems it ``` Behind them: argon2id hashing, a short-lived access JWT with a revocable refresh row, single-use hashed reset tokens, and rate limiting on login, signup and reset keyed on **both** IP and email. Login also refuses to reveal which addresses have accounts — a wrong password and an unknown email return identical bodies, identical statuses, and comparable timing. That list is the argument for not writing auth yourself. Every item on it is something that gets missed at the end of a long Friday. ## Guarding a route Add one line to each of your four route files: ```ts title="src/routes/todos/index.post.ts" ins={5} export default route( { summary: 'Create a todo', tags: ['todos'], auth: true, body: z.object({ /* … */ }), }, async (ctx) => { const [todo] = await ctx.db .insert(todos) .values({ id: crypto.randomUUID(), userId: ctx.user.id, // ← no null check // … }) .returning() ctx.status = 201 return todo }, ) ``` `ctx.user.id`, with no `if (!ctx.user)` in front of it. The guard runs **before** the handler, so a route declaring `auth: true` is unreachable anonymously — and TypeScript knows, narrowing `ctx.user` to non-null inside it. Delete the `auth: true` line and that `ctx.user.id` stops compiling. The type system is enforcing the guard, not just describing it. Where the credential came from — `Authorization`, a cookie, `?access_token=` — is core's business. Which provider validated it is the brick's. Swap `auth-basic` for [Clerk](https://theoven.app/docs/bricks/auth-clerk/) and this file does not change. ## Scoping the queries A guard says *someone* is signed in. It does not say the todo is theirs. That is your job, and it is the part worth being careful about. ### Listing ```ts title="src/routes/todos/index.get.ts" export default route( { auth: true, summary: 'List your todos', tags: ['todos'], query: z.object({ done: z.stringbool().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), }), }, (ctx) => { const mine = eq(todos.userId, ctx.user.id) return ctx.db .select() .from(todos) .where(ctx.query.done === undefined ? mine : and(mine, eq(todos.done, ctx.query.done))) .orderBy(desc(todos.createdAt)) .limit(ctx.query.limit) }, ) ``` `mine` is built once and every branch includes it. Written as a chain of optional filters, the ownership check is one `if` away from being skipped — this way it cannot be. `z.stringbool()` turns `?done=true` into a real boolean; `"false"`, `"0"` and `"no"` all work, which `Boolean("false") === true` famously does not. ### Updating and deleting ```ts title="src/routes/todos/[id].patch.ts" const [updated] = await ctx.db .update(todos) .set(ctx.body) .where(and(eq(todos.id, ctx.params.id), eq(todos.userId, ctx.user.id))) .returning() if (!updated) throw new NotFound(`No todo with id ${ctx.params.id}.`) return updated ``` The ownership check is **in the `where` clause**, not in an `if` after the read. That matters for two reasons. It is one query rather than a read-then-write, so nothing can change between them. And it fails as a `404`, not a `403` — which is the right answer, because telling a stranger "that exists but is not yours" confirms the id exists. `DELETE` gets the same treatment. ## See it work ```bash # Ada signs up and creates something ADA=$(curl -s -X POST localhost:3000/auth/signup -H 'content-type: application/json' \ -d '{"name":"Ada","email":"ada@example.com","password":"correct-horse"}' | jq -r .accessToken) ID=$(curl -s -X POST localhost:3000/todos -H "authorization: Bearer $ADA" \ -H 'content-type: application/json' -d '{"title":"Ada private"}' | jq -r .id) # Mallory signs up and goes looking MAL=$(curl -s -X POST localhost:3000/auth/signup -H 'content-type: application/json' \ -d '{"name":"Mallory","email":"mal@example.com","password":"correct-horse"}' | jq -r .accessToken) curl -s localhost:3000/todos -H "authorization: Bearer $MAL" # [] curl -s -o /dev/null -w '%{http_code}\n' -X PATCH localhost:3000/todos/$ID \ -H "authorization: Bearer $MAL" -H 'content-type: application/json' -d '{"title":"pwned"}' # 404 curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/todos/$ID \ -H "authorization: Bearer $MAL" # 404 ``` Ada's todo is invisible, unmodifiable and undeletable. And without a token at all: ```bash curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/todos # 401 ``` ## When "signed in" is not enough `auth: true` means *someone*. For anything more, write a named policy: ```ts title="src/app.ts" const policies = { admin: (user) => user.raw.role === 'admin', } app.use(auth(provider, { policies })) ``` ```ts export default route({ auth: 'admin' }, handler) ``` A policy is a plain function — testable, greppable, and it appears in your OpenAPI document with its name and a documented `403`. There is deliberately no built-in `role: 'admin'` guard: roles are not normalised across providers, so a portable one would fail silently on any provider that models permissions differently. [Next: testing it →](https://theoven.app/docs/build/testing/) --- ## Chat: messages and pagination Source: https://theoven.app/docs/projects/chat/messages/ > Send a message, and page backwards through history without showing anyone the same message twice. ## Sending ```ts title="src/routes/rooms/[id]/messages.post.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const body = z.object({ body: z.string().min(1).max(4000) }) export default async (ctx) => { await requireMember(ctx.db, ctx.params.id, ctx.user.id) const [message] = await ctx.db .insert(messages) .values({ id: crypto.randomUUID(), roomId: ctx.params.id, authorId: ctx.user.id, body: ctx.body.body, }) .returning() ctx.status = 201 return message } ``` `requireMember` first, so a stranger's message is refused before it is written. The schema caps the body at 4000 characters, which happens before the handler runs at all. ## Why offset pagination is wrong here The obvious version: ```ts // Do not do this. db.select().from(messages).orderBy(desc(messages.createdAt)).limit(20).offset(page * 20) ``` It works on a table nobody is writing to. On a chat room it does not, and the failure is quiet: 1. A reader loads page 1 — messages 100 down to 81. 2. Three people send a message. 3. The reader loads page 2 — offset 20, which is now messages 83 down to 64. **Messages 83, 82 and 81 appear twice**, and nothing errors. Scroll further and the drift grows. Offset counts from a moving end. ## Cursor pagination A cursor names a **row**, not a position, so new messages above it change nothing: ```ts title="src/routes/rooms/[id]/messages.get.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const query = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), before: z.uuid().optional(), }) export default async (ctx) => { await requireMember(ctx.db, ctx.params.id, ctx.user.id) // The cursor is a message id; its timestamp is what the comparison actually uses. const cursor = ctx.query.before ? (await ctx.db.select().from(messages).where(eq(messages.id, ctx.query.before)))[0] : undefined const rows = await ctx.db .select() .from(messages) .where( and( eq(messages.roomId, ctx.params.id), cursor ? lt(messages.createdAt, cursor.createdAt) : undefined, ), ) .orderBy(desc(messages.createdAt)) .limit(ctx.query.limit) return { messages: rows, // Null means the end. Returning the last id unconditionally makes a client loop forever. next: rows.length === ctx.query.limit ? (rows[rows.length - 1]?.id ?? null) : null, } } ``` `and(...)` with an `undefined` member simply drops it, which is why the first page and every later page are the same query rather than two branches. ### It was run Against a room with 25 messages, `limit=10`: ``` page 1 : message 24, 23, 22, 21, 20, 19, 18, 17, 16, 15 next = m15 page 2 : message 14, 13, 12, 11, 10, 9, 8, 7, 6, 5 next = m5 page 3 : message 4, 3, 2, 1, 0 next = null ``` Twenty-five messages, no repeats, and the third page reports the end rather than looping. `lt(createdAt)` on a non-unique column can skip a row when two messages share a timestamp. It is rare and it is real. The fix is to compare on a tuple — `(createdAt, id)` — so ties break deterministically. In SQLite that is `where (created_at, id) < (?, ?)` via `sql`; in Postgres it is the same. Worth doing on a busy room; the code above is the simpler version, and this is the caveat that makes choosing it deliberate rather than accidental. ## Why `before` is validated as a uuid `z.uuid()` on the cursor means a malformed one is a `422` rather than a lookup that returns nothing and silently restarts the reader at the top of history. A cursor that fails closed is far easier to debug than one that quietly resets. ## What a client does with this ```js let before = null async function older() { const url = `/rooms/${roomId}/messages?limit=50${before ? `&before=${before}` : ''}` const page = await fetch(url, { headers }).then((r) => r.json()) before = page.next return { messages: page.messages, done: page.next === null } } ``` The client holds one string. It does not count, and it does not care how many messages arrived while the reader was scrolling. [Live delivery →](https://theoven.app/docs/projects/chat/realtime/) --- ## Real-time notifications Source: https://theoven.app/docs/recipes/realtime/ > Push updates to a signed-in user over a WebSocket that is guarded by the same auth as everything else. A socket per signed-in user, a way to push to them from anywhere in your app, and a browser client that reconnects. Twenty-five minutes, and no extra package — WebSockets are in core. 1. ### The socket route ```ts title="src/app.ts" import { z } from 'zod' app.ws('/ws', { auth: true }, { // Runs *before* the socket exists. Throwing here refuses the connection. upgrade: (ctx) => ({ userId: ctx.user.id }), open: (socket) => socket.subscribe(`user:${socket.data.data.userId}`), close: (socket) => socket.unsubscribe(`user:${socket.data.data.userId}`), }) ``` That is the whole server side. Each connection subscribes to a topic named after its own user, so pushing to one person is publishing to one topic. `app.ws()` registers an **ordinary route** that happens to upgrade, so the guard runs before a socket exists. A separate socket entry point means a second, weaker authentication story — and that is how a socket endpoint becomes the unguarded way into an application. Here there is one guard, and it runs first. 2. ### Push from anywhere ```ts title="src/routes/orders/index.post.ts" export const auth = true export default async ({ body, db, user }) => { const [order] = await db.insert(orders).values({ ...body, userId: user.id }).returning() app.publish(`user:${user.id}`, JSON.stringify({ type: 'order.created', order })) return order } ``` `publish` is Bun's own pub/sub, so a broadcast does not walk a list of connections in JavaScript. 3. ### The browser side ```js const socket = new WebSocket(`wss://${location.host}/ws?access_token=${token}`) socket.onmessage = (event) => { const message = JSON.parse(event.data) if (message.type === 'order.created') addOrderToList(message.order) } // Browsers do not reconnect WebSockets. This is yours to write. socket.onclose = () => setTimeout(connect, 1000) ``` A browser `WebSocket` cannot send an `Authorization` header. Core reads `?access_token=` as well as the header and the cookie, so the guard works either way. Query strings end up in access logs and `Referer` headers, so prefer the cookie when the socket is same-origin — `new WebSocket(url)` sends cookies automatically, and the refresh cookie is already httpOnly. ## Rooms, not just users The topic is a string, so anything can be a channel: ```ts const rooms = routerFor({ prefix: '/rooms', auth: true }) rooms.ws('/:id', { params: z.object({ id: z.uuid() }) }, { upgrade: (ctx) => ({ room: ctx.params.id, userId: ctx.user.id }), open: (socket) => socket.subscribe(socket.data.data.room), message: (socket, text) => app.publish(socket.data.data.room, String(text)), close: (socket) => socket.unsubscribe(socket.data.data.room), }) app.use(rooms) ``` On a [router](https://theoven.app/docs/reference/routers/), the prefix, tags and `auth` apply to sockets too — one declaration guards every socket in the group. `auth: true` proves who they are. It does not prove they belong in room `:id`. Check that in `upgrade` and throw — it is the last moment before a socket exists: ```ts upgrade: async (ctx) => { if (!(await isMember(ctx.user.id, ctx.params.id))) throw new Forbidden('Not in this room.') return { room: ctx.params.id, userId: ctx.user.id } } ``` ## When you do not need a socket at all If the browser only listens — a progress bar, a feed, tokens streaming from a model — server-sent events are less to get wrong. Plain HTTP, and the browser reconnects on its own: ```ts export default async (ctx) => sse( async (stream) => { for await (const update of watchJob(ctx.params.id)) { stream.send({ event: 'progress', data: update }) } }, { signal: ctx.req.signal }, ) ``` Pass `ctx.req.signal`. Without it, a closed tab leaves the producer running and writing into nothing, forever. ## What this does not do - **`app.publish()` needs a listening server.** It returns `0` when a `Request` was dispatched directly, which is what happens in tests — assert on the handler, not the broadcast. - **No delivery guarantee.** A user who is offline gets nothing. If a notification must survive being missed, write it to a table and have the client fetch on connect; the socket is the fast path, not the record. - **No presence.** Bun's topics are strings; who is in a room is your application's to track. - **One process.** `publish` reaches the connections held by *this* server. Behind a load balancer, a user connected to another instance hears nothing — you need Redis pub/sub between instances, which is not built in. - **No reconnect state.** SSE's `Last-Event-ID` arrives as a header, and resuming from it is yours to implement. --- ## Trello: ordering that survives dragging Source: https://theoven.app/docs/projects/trello/ordering/ > Move a card by writing one row — and the measured point at which that stops working. ## Why not an integer The obvious schema is `position: 0, 1, 2, 3`. Moving the last card to the front means renumbering every card in the list — four writes for one drag, forty for a real board. Two people dragging at once interleave those writes and produce an order that is wrong for both. ## Gaps instead of counts Store positions spaced apart, and a move becomes **one write**: put the card halfway between its new neighbours. ```ts title="src/order.ts" /** The gap between appended items. Large enough that inserts rarely need neighbours close. */ export const STEP = 1024 /** * A position between two neighbours. * * `undefined` means "no neighbour on that side" — dropping at the very top or the very bottom. */ export function between(before?: number, after?: number): number { if (before === undefined && after === undefined) return STEP if (before === undefined) return after / 2 if (after === undefined) return before + STEP return (before + after) / 2 } ``` ## The move endpoint ```ts title="src/routes/cards/[id]/move.patch.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const body = z.object({ listId: z.uuid(), /** The card this one should land after. Omitted means "to the top". */ after: z.uuid().optional(), }) export default async ({ body, db, params, user }) => { const [card] = await db.select().from(cards).where(eq(cards.id, params.id)) if (!card) throw new NotFound('No such card.') const [target] = await db.select().from(lists).where(eq(lists.id, body.listId)) if (!target) throw new NotFound('No such list.') await requireRole(db, target.boardId, user.id, 'editor') // The neighbour above, and whatever is directly below it. const above = body.after ? (await db.select().from(cards).where(eq(cards.id, body.after)))[0] : undefined const [below] = await db .select() .from(cards) .where( and( eq(cards.listId, body.listId), above ? gt(cards.position, above.position) : undefined, ), ) .orderBy(asc(cards.position)) .limit(1) const [moved] = await db .update(cards) .set({ listId: body.listId, position: between(above?.position, below?.position) }) .where(eq(cards.id, params.id)) .returning() return moved } ``` One `UPDATE`, whatever the list length. Moving a card between lists is the same operation — the `listId` changes alongside the position. Verified: ``` initial : a@1024 b@2048 c@3072 moved c : a@1024 c@1536 b@2048 ``` ## Where it breaks, measured Every drop into the same gap halves it. Doubles have a 52-bit mantissa, so the halving cannot continue forever: ``` between 1024 and 2048 : 52 moves between 1024 and 1025 : 42 moves ``` After that, `(before + after) / 2` returns a value **equal to one of its neighbours** and the order becomes ambiguous — two cards with the same position, sorted by whatever the database feels like. Not 42 drags on the board — 42 drags into *the same gap*. A team rearranging one column of a sprint board reaches that in an afternoon, and the symptom is cards that swap places on reload with no error anywhere. ## Rebalancing Detect the collapse and respace the list: ```ts title="src/order.ts" /** True when the gap can no longer be halved. */ export function collapsed(position: number, before?: number, after?: number): boolean { return position === before || position === after } export async function rebalance(db, listId: string) { const rows = await db .select({ id: cards.id }) .from(cards) .where(eq(cards.listId, listId)) .orderBy(asc(cards.position)) // Rewrites the list — the thing gaps exist to avoid — but only when arithmetic ran out, // which is rare enough to be worth the occasional expensive write. await transaction(db, async (tx) => { for (const [index, row] of rows.entries()) { await tx.update(cards).set({ position: (index + 1) * STEP }).where(eq(cards.id, row.id)) } }) } ``` In the move endpoint: ```ts ins={3-6} const position = between(above?.position, below?.position) if (collapsed(position, above?.position, below?.position)) { await rebalance(db, body.listId) return moveCard(db, params.id, body) // retry once, against fresh positions } ``` Verified: ``` before rebalance : a@1024 c@1536 b@2048 after rebalance : a@1024 c@2048 b@3072 ``` In a transaction, because a half-rebalanced list is worse than a collapsed one. ## The alternative Lexicographic keys — strings like `"n"`, `"n5"`, `"nF"` — subdivide forever without floating-point limits. That is what Figma and Jira use, and there is no rebalance. The trade is that the keys are opaque, sort as text, and need a library to generate. For a board app, floats plus a rare rebalance is less machinery for the same result, and the failure mode is now measured rather than a surprise. If you are building something where a rebalance is unacceptable, reach for lexorank. ## Concurrency Two people moving different cards into the same gap can both compute the same midpoint. Both writes succeed and the cards tie. For a board app that is cosmetic and self-corrects on the next move. If it matters, put a unique index on `(list_id, position)` and retry on conflict — which turns a silent tie into a refusal you can handle. [Activity and shipping →](https://theoven.app/docs/projects/trello/activity/) --- ## YouTube: watching and visibility Source: https://theoven.app/docs/projects/youtube/watching/ > One visibility rule used by four routes, and a view count that is not trivially inflatable. ## Four routes, one rule A private video is reachable from its own URL, the channel page, a playlist, and the feed. Four paths, and a rule implemented four times is a rule implemented three times and forgotten once. ```ts title="src/visibility.ts" /** * Whether this viewer may see this video. * * - the owner sees everything, including their own processing and failed uploads * - `unlisted` is visible to anyone holding the id — that is what unlisted means * - `public` is visible once it is `ready` */ export function canWatch(video, viewerId?: string): boolean { if (video.ownerId === viewerId) return true if (video.status !== 'ready') return false return video.visibility === 'public' || video.visibility === 'unlisted' } /** The same rule, as a refusal. 404 rather than 403: a private video's existence is private. */ export function assertWatchable(video, viewerId?: string) { if (!video || !canWatch(video, viewerId)) throw new NotFound('No such video.') return video } ``` A `403` on a private video confirms it exists. Someone enumerating ids learns which are real, which is exactly the information the setting is meant to withhold. `404` for both cases costs nothing and tells them nothing. ## The watch route ```ts title="src/routes/videos/[id].get.ts" // No `auth` export — anyone can watch a public video, and `ctx.user` is still populated // when a token is present, which is how the owner sees their own drafts. export const params = z.object({ id: z.uuid() }) export default async ({ db, params, user }) => { const [row] = await db .select({ video: videos, ownerId: channels.ownerId, handle: channels.handle }) .from(videos) .innerJoin(channels, eq(channels.id, videos.channelId)) .where(eq(videos.id, params.id)) const video = assertWatchable( row && { ...row.video, ownerId: row.ownerId }, user?.id, ) return { ...video, channel: { handle: row.handle } } } ``` `user?.id` — optional, because this route is public. A route with no `auth` export still populates `ctx.user` when a valid token is present, which is what lets one handler serve both the anonymous viewer and the owner previewing an unpublished video. ## Counting a view The naive version is one `UPDATE` per request, and it is wrong twice over: ```ts // Inflatable by refresh, and a write on the hottest read path in the app. await db.update(videos).set({ views: sql`${videos.views} + 1` }).where(eq(videos.id, id)) ``` The increment itself is right — `sql\`${videos.views} + 1\`` is atomic, so two concurrent views do not lose one. Verified: ``` views after 2 increments: 2 ``` The problem is *when* it runs. A separate endpoint, called once the player has actually played something, is both more honest and off the read path: ```ts title="src/routes/videos/[id]/view.post.ts" export const params = z.object({ id: z.uuid() }) export default async ({ cache, db, ip, params, user }) => { const [row] = await db .select({ video: videos, ownerId: channels.ownerId }) .from(videos) .innerJoin(channels, eq(channels.id, videos.channelId)) .where(eq(videos.id, params.id)) assertWatchable(row && { ...row.video, ownerId: row.ownerId }, user?.id) // One view per viewer per video per hour. Not fraud-proof — nothing this cheap is — but it // stops a refresh loop, which is the entire problem at this scale. const seen = `view:${params.id}:${user?.id ?? ip}` if (await cache.get(seen)) return { counted: false } await cache.set(seen, true, { ttl: 3_600_000 }) await db.update(videos).set({ views: sql`${videos.views} + 1` }).where(eq(videos.id, params.id)) return { counted: true } } ``` Someone determined, with a script and a proxy pool. Real platforms count views in a stream processor with heuristics on watch time, and that is a different system. This stops the accidental case — a refresh, a player retry, a bot crawling — which is what actually inflates numbers on a small platform. ## Serving the file ```ts title="src/routes/videos/[id]/stream.get.ts" export const params = z.object({ id: z.uuid() }) export default async (ctx) => { // The same lookup and `assertWatchable` as above. const video = await watchableVideo(ctx.db, ctx.params.id, ctx.user?.id) if (ctx.storage.canPresign) { // Straight from the bucket. Your server never touches the bytes. return ctx.redirect(ctx.storage.presignDownload(video.sourceKey, { expiresIn: 3600 })) } return ctx.storage.download(video.sourceKey) } ``` The presigned branch matters more here than anywhere else in these projects: video is large, and proxying every byte through your application server is the difference between a small instance and a large one. The fallback streams — `download()` returns a lazy `Blob`, so returning it does not buffer the file into memory. It does not support range requests, so seeking will not work; that is the honest cost of the development path, and the reason production should presign. ## Listing a channel's videos ```ts title="src/routes/channels/[handle]/videos.get.ts" export default async ({ db, params, user }) => { const [channel] = await db .select() .from(channels) .where(eq(channels.handle, params.handle.toLowerCase())) if (!channel) throw new NotFound('No such channel.') const owner = channel.ownerId === user?.id return db .select() .from(videos) .where( and( eq(videos.channelId, channel.id), // The owner sees drafts and failures; everyone else sees published videos only. owner ? undefined : and(eq(videos.status, 'ready'), eq(videos.visibility, 'public')), ), ) .orderBy(desc(videos.publishedAt)) } ``` The same rule as `canWatch`, expressed as a filter rather than a check — because filtering in the database is the only version that works on a list. Keeping them in agreement is a test worth writing: a private video must not appear here, and the owner's must. [Playlists →](https://theoven.app/docs/projects/youtube/playlists/) --- ## 5. Testing it Source: https://theoven.app/docs/build/testing/ > No server, no port, no mocks — and the tests that matter most. `app.fetch(request)` runs the whole pipeline — routing, middleware, the auth guard, validation, your handler, serialisation — and hands back a `Response`. Nothing is mocked and no port is bound. ## The setup ```ts title="src/app.test.ts" const send = (path: string, init?: RequestInit) => app.fetch(new Request(`http://localhost${path}`, init)) async function signUp(email: string): Promise { const response = await send('/auth/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Test', email, password: 'correct-horse' }), }) return ((await response.json()) as { accessToken: string }).accessToken } function as(token: string, body?: unknown, method = 'GET'): RequestInit { return { method, headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), } } let ada = '' let mallory = '' beforeAll(async () => { // A fresh :memory: database has no tables — the migrations on disk were applied to your // development file, not to this one. migrate(client, { migrationsFolder: './drizzle' }) await app.ready() ada = await signUp(`ada-${crypto.randomUUID()}@example.com`) mallory = await signUp(`mal-${crypto.randomUUID()}@example.com`) }) afterAll(() => app.close({ timeout: 1000 })) ``` Run it against a throwaway database: ```bash DATABASE_URL=:memory: AUTH_SECRET=test-secret bun test ``` `migrate(client, …)` creates the tables on the connection in `client.ts` — and because `db.ts` **adopted** that connection rather than opening its own, `ctx.db` sees them. Two `:memory:` connections would be two databases, and every test would fail with `no such table`. Two users are created up front, because the tests that matter need someone to attack with. ## The happy path ```ts describe('todos', () => { test('a todo can be created, listed, completed and deleted', async () => { const created = await send('/todos', as(ada, { title: 'Write tests' }, 'POST')) expect(created.status).toBe(201) const todo = (await created.json()) as { id: string; done: boolean } expect(todo.done).toBe(false) const listed = (await (await send('/todos', as(ada))).json()) as Array<{ id: string }> expect(listed.some((entry) => entry.id === todo.id)).toBe(true) const patched = await send(`/todos/${todo.id}`, as(ada, { done: true }, 'PATCH')) expect(((await patched.json()) as { done: boolean }).done).toBe(true) expect((await send(`/todos/${todo.id}`, as(ada, undefined, 'DELETE'))).status).toBe(204) }) }) ``` Note what is **not** here: no server started, no port chosen, no `supertest`, no cleanup between tests beyond a fresh database per run. ## The refusals Failures are where the bugs live, so test them at least as hard as the successes: ```ts describe('validation', () => { test('an empty title is a 422 naming the field', async () => { const response = await send('/todos', as(ada, { title: '' }, 'POST')) expect(response.status).toBe(422) const problem = (await response.json()) as { errors: Array<{ path: string }> } expect(problem.errors[0]?.path).toBe('title') }) test('a patch with no fields is refused', async () => { const created = await send('/todos', as(ada, { title: 'x' }, 'POST')) const { id } = (await created.json()) as { id: string } expect((await send(`/todos/${id}`, as(ada, {}, 'PATCH'))).status).toBe(422) }) test('a malformed id never reaches the database', async () => { const response = await send('/todos/not-a-uuid', as(ada, { done: true }, 'PATCH')) expect(response.status).toBe(422) }) }) ``` Assert on `path`, not on `message`. Validator messages get reworded between versions, and a test that breaks when prose changes is a test somebody deletes. ## The tests that matter most ```ts describe('one user cannot touch another user\'s todos', () => { test('they are invisible, and cannot be changed or deleted', async () => { const created = await send('/todos', as(ada, { title: 'Ada private' }, 'POST')) const { id } = (await created.json()) as { id: string } const theirs = (await (await send('/todos', as(mallory))).json()) as unknown[] expect(theirs.some((entry) => (entry as { id: string }).id === id)).toBe(false) expect((await send(`/todos/${id}`, as(mallory, { title: 'pwned' }, 'PATCH'))).status).toBe(404) expect((await send(`/todos/${id}`, as(mallory, undefined, 'DELETE'))).status).toBe(404) // And it is still Ada's, unchanged. const mine = (await (await send('/todos', as(ada))).json()) as Array<{ id: string; title: string }> expect(mine.find((entry) => entry.id === id)?.title).toBe('Ada private') }) test('every todo route refuses an anonymous request', async () => { for (const [path, method] of [ ['/todos', 'GET'], ['/todos', 'POST'], [`/todos/${crypto.randomUUID()}`, 'PATCH'], [`/todos/${crypto.randomUUID()}`, 'DELETE'], ] as const) { expect((await send(path, { method })).status, `${method} ${path}`).toBe(401) } }) }) ``` Every query in this app is scoped by `userId`. These two tests are what stop that being true only until somebody refactors — and the second one loops over every route deliberately, because the route people forget to guard is the one added last. ## Verify a test can fail Before trusting any of it, break something on purpose: ```ts // Temporarily, in [id].patch.ts .where(eq(todos.id, ctx.params.id)) // ownership check removed ``` ``` 1 fail one user cannot touch another user's todos > they are invisible… ``` Then put it back. A test that cannot fail is a comment with a runtime cost, and every suite has a few. This is the cheapest habit in software and almost nobody does it. ## Result ```bash DATABASE_URL=:memory: AUTH_SECRET=test-secret bun test ``` ``` 8 pass 0 fail 20 expect() calls ``` [Next: shipping it →](https://theoven.app/docs/build/shipping/) --- ## Chat: live delivery Source: https://theoven.app/docs/projects/chat/realtime/ > A socket that refuses non-members before it opens, and a broadcast that does not walk a list. ## The socket ```ts title="src/app.ts" app.ws('/rooms/:id/ws', { auth: true, params: z.object({ id: z.uuid() }) }, { /** * Runs before the socket exists. Throwing refuses the connection, and the client gets the * usual problem+json rather than an opaque close. */ upgrade: async (ctx) => { await requireMember(ctx.db, ctx.params.id, ctx.user.id) return { room: ctx.params.id, userId: ctx.user.id } }, open: (socket) => socket.subscribe(`room:${socket.data.data.room}`), close: (socket) => socket.unsubscribe(`room:${socket.data.data.room}`), }) ``` Three things have already happened by the time `upgrade` runs: the auth brick populated `ctx.user`, the guard refused an anonymous request, and the `params` schema rejected a malformed room id. Only then does membership get checked, and only then does a socket exist. `auth: true` proves who they are. It does not prove they belong in room `4f2a` — and a socket endpoint that skips that check is how someone reads a room they were removed from. The check is the same `requireMember` the HTTP routes use, which is the point of having written it once. ## Broadcasting Add one line to the send route: ```ts title="src/routes/rooms/[id]/messages.post.ts" ins={11} export default async (ctx) => { await requireMember(ctx.db, ctx.params.id, ctx.user.id) const [message] = await ctx.db .insert(messages) .values({ id: crypto.randomUUID(), roomId: ctx.params.id, authorId: ctx.user.id, body: ctx.body.body }) .returning() // After the write, so nobody is shown a message that then failed to save. app.publish(`room:${ctx.params.id}`, JSON.stringify({ type: 'message', message })) ctx.status = 201 return message } ``` `publish` is Bun's own pub/sub — the broadcast does not walk a list of connections in JavaScript. The ordering matters: publish after the insert. Publishing first is faster and occasionally sends a message that the database then rejects, which the sender sees and the readers do not. ## The client ```js const socket = new WebSocket(`wss://${location.host}/rooms/${roomId}/ws?access_token=${token}`) socket.onmessage = (event) => { const { type, message } = JSON.parse(event.data) if (type === 'message') append(message) } socket.onclose = () => setTimeout(connect, 1000) ``` A browser `WebSocket` cannot set an `Authorization` header, so core also reads `?access_token=`. On a same-origin socket prefer the cookie — `new WebSocket(url)` sends it automatically and it never touches your JavaScript. ## Typing indicators Ephemeral by nature, so they never touch the database: ```ts app.ws('/rooms/:id/ws', { auth: true, params }, { upgrade: async (ctx) => { /* as above */ }, open: (socket) => socket.subscribe(`room:${socket.data.data.room}`), message: (socket, raw) => { const parsed = JSON.parse(String(raw)) if (parsed.type !== 'typing') return app.publish( `room:${socket.data.data.room}`, JSON.stringify({ type: 'typing', userId: socket.data.data.userId }), ) }, close: (socket) => socket.unsubscribe(`room:${socket.data.data.room}`), }) ``` Note what the handler does **not** trust: the room comes from `socket.data`, set during `upgrade`, never from the incoming frame. A client that sends `{ type: 'typing', room: 'someone-elses' }` gets its own room published to, because the frame's opinion is ignored. ## Where this stops working - **One process.** `publish` reaches connections held by *this* server. Behind a load balancer, two people in the same room on different instances cannot see each other. The fix is Redis pub/sub between instances, and it is not built in. - **No delivery guarantee.** Someone offline gets nothing. History is the record; the socket is the fast path. That is why the send route writes first and publishes second. - **No presence.** Bun's topics are strings; who is currently in a room is yours to track. - **`app.publish()` returns `0`** when a `Request` was dispatched directly, which is what happens in tests — assert on the row and the response, not the broadcast. - **No read receipts, edits or deletes.** Each is a column and a route; none changes the shape above. ## What you built | | | | --- | --- | | Rooms, membership, join and leave | with the last-owner case handled | | History | cursor-paged, verified over 25 messages and three pages | | Sending | membership-checked, size-capped before the handler | | Live delivery | guarded by the same check as the HTTP routes | Next: [Trello](https://theoven.app/docs/projects/trello/) for permissions and ordering, or [YouTube](https://theoven.app/docs/projects/youtube/) for uploads and background work. --- ## Trello: activity and shipping Source: https://theoven.app/docs/projects/trello/activity/ > An audit trail written in the same transaction as the change, and what to fix before deploying. ## The log has to be in the transaction An activity row written *after* the change is a row that goes missing whenever the process dies in between — and an audit trail with holes is worse than none, because people trust it. ```ts title="src/activity.ts" /** Records something. Call it with the transaction, never with `db`. */ export function record( tx, boardId: string, actorId: string, action: string, detail?: Record, ) { return tx.insert(activity).values({ id: crypto.randomUUID(), boardId, actorId, action, detail: detail ?? null, }) } ``` ```ts title="src/routes/lists/[id]/cards.post.ts" ins={7} await transaction(db, async (tx) => { const [created] = await tx .insert(cards) .values({ id, listId: params.id, title: body.title, position }) .returning() await record(tx, list.boardId, user.id, 'card.created', { cardId: id, title: body.title }) return created }) ``` Both rows or neither. It compiles, it works in tests, and it silently writes the log outside the transaction — so a rollback leaves an activity entry describing a card that does not exist. The signature above takes the transaction as its first argument to make the right thing the easy thing, but nothing stops you passing the wrong one. It is worth a review comment. ## A per-request transaction instead If most routes on a board write more than one row, scope it once rather than per handler: ```ts title="src/app.ts" app.use('/boards', transactional()) ``` `ctx.db` becomes the transaction-scoped client for those requests, so handlers need no change and a request that throws — from a handler, from validation, from a middleware inside it — rolls back everything it wrote. Scoped to `/boards` deliberately. Applied globally it holds a transaction open for the length of every request including the slow ones, which is how a connection pool runs out. ## Reading the log ```ts title="src/routes/boards/[id]/activity.get.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const query = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), before: z.uuid().optional(), }) export default async (ctx) => { await requireRole(ctx.db, ctx.params.id, ctx.user.id, 'viewer') const cursor = ctx.query.before ? (await ctx.db.select().from(activity).where(eq(activity.id, ctx.query.before)))[0] : undefined const rows = await ctx.db .select() .from(activity) .where(and(eq(activity.boardId, ctx.params.id), cursor ? lt(activity.at, cursor.at) : undefined)) .orderBy(desc(activity.at)) .limit(ctx.query.limit) return { activity: rows, next: rows.length === ctx.query.limit ? rows.at(-1)?.id : null } } ``` The same cursor shape as [chat history](https://theoven.app/docs/projects/chat/messages/), and for the same reason: an append-only feed with offsets shows the reader duplicates. ## Live boards ```ts title="src/app.ts" app.ws('/boards/:id/ws', { auth: true, params: z.object({ id: z.uuid() }) }, { upgrade: async (ctx) => { await requireRole(ctx.db, ctx.params.id, ctx.user.id, 'viewer') return { board: ctx.params.id } }, open: (socket) => socket.subscribe(`board:${socket.data.data.board}`), close: (socket) => socket.unsubscribe(`board:${socket.data.data.board}`), }) ``` `viewer`, not `editor` — watching a board is not editing it. Then publish after each mutation, outside the transaction, so nobody is told about a change that then rolled back: ```ts app.publish(`board:${boardId}`, JSON.stringify({ type: 'card.moved', card: moved })) ``` ## Before you deploy | | | | --- | --- | | Move the queue and cache off memory | both refuse to boot in production, which is the reminder | | `transactional()` scoped to write paths | not globally | | Rebalance on collapse | chapter 4 — without it, ordering silently breaks after 42 drags into one gap | | The empty-list `inArray` guard | chapter 3 — a brand-new board is the first thing anyone creates | | Consider a unique index on `(list_id, position)` | turns a concurrent tie into a refusal you can retry | ## What this does not do - **No card assignment, labels, due dates or comments.** Each is a table and a route; none changes the shapes here. - **No board-level sharing links.** Access is membership only. - **Ordering ties are possible** under concurrent moves into the same gap — cosmetic, and it self-corrects. - **The activity log is never pruned.** It grows forever; a [cron job](https://theoven.app/docs/bricks/queue/#cron) is the place for a retention policy. Next: [YouTube](https://theoven.app/docs/projects/youtube/) for uploads and background work. --- ## YouTube: playlists Source: https://theoven.app/docs/projects/youtube/playlists/ > Ordered collections, reusing the positions from Trello — and the visibility trap they introduce. ## The tables ```ts title="src/schema.ts" export const playlists = sqliteTable('playlists', { id: text('id').primaryKey(), channelId: text('channel_id').notNull(), title: text('title').notNull(), visibility: text('visibility', { enum: ['public', 'unlisted', 'private'] }) .notNull() .default('private'), }) export const playlistItems = sqliteTable( 'playlist_items', { id: text('id').primaryKey(), playlistId: text('playlist_id').notNull(), videoId: text('video_id').notNull(), /** Same fractional ordering as the Trello cards, and the same rebalance caveat. */ position: real('position').notNull(), addedAt: integer('added_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }, (table) => [ index('playlist_items_idx').on(table.playlistId, table.position), // The same video twice in one playlist is a mistake, not a feature. uniqueIndex('playlist_items_unique').on(table.playlistId, table.videoId), ], ) ``` The ordering is [exactly the Trello design](https://theoven.app/docs/projects/trello/ordering/) — `between()`, `STEP = 1024`, and the same rebalance when floats collapse. Nothing new to build; reuse `src/order.ts`. ## Adding a video ```ts title="src/routes/playlists/[id]/items.post.ts" export const auth = true export const params = z.object({ id: z.uuid() }) export const body = z.object({ videoId: z.uuid() }) export default async ({ body, db, params, user }) => { const playlist = await requireOwnedPlaylist(db, params.id, user.id) // The video must be one this person may actually see — otherwise "add to playlist" becomes // a way to confirm that a private video exists. const video = await watchableVideo(db, body.videoId, user.id) const [last] = await db .select({ position: playlistItems.position }) .from(playlistItems) .where(eq(playlistItems.playlistId, params.id)) .orderBy(desc(playlistItems.position)) .limit(1) try { const [item] = await db .insert(playlistItems) .values({ id: crypto.randomUUID(), playlistId: params.id, videoId: video.id, position: (last?.position ?? 0) + STEP, }) .returning() return item } catch { throw new Conflict('That video is already in this playlist.') } } ``` ## The trap: visibility is not inherited A playlist is public. A video in it is private. What does the playlist return? ```ts title="src/routes/playlists/[id].get.ts" export const params = z.object({ id: z.uuid() }) export default async ({ db, params, user }) => { const [playlist] = await db.select().from(playlists).where(eq(playlists.id, params.id)) assertPlaylistVisible(playlist, user?.id) const rows = await db .select({ item: playlistItems, video: videos, ownerId: channels.ownerId }) .from(playlistItems) .innerJoin(videos, eq(videos.id, playlistItems.videoId)) .innerJoin(channels, eq(channels.id, videos.channelId)) .where(eq(playlistItems.playlistId, params.id)) .orderBy(asc(playlistItems.position)) return { ...playlist, // Filtered per video, per viewer. A public playlist does not make its contents public. items: rows .filter((row) => canWatch({ ...row.video, ownerId: row.ownerId }, user?.id)) .map((row) => ({ position: row.item.position, video: row.video })), } } ``` The obvious implementation joins and returns everything, because the *playlist* was public. That publishes every private video anyone ever added to it — titles, thumbnails, ids — through a route nobody thought of as a video route. Visibility belongs to the video, always, and is re-checked wherever a video is returned. That is why `canWatch` is a function rather than four copies of a condition. A consequence worth telling users about: two people can open the same public playlist and see different numbers of videos. That is correct, and it surprises people, so the API returning a filtered list rather than a count-with-holes is the kinder shape. ## Reordering ```ts title="src/routes/playlists/[id]/items/[itemId].patch.ts" export const auth = true export const params = z.object({ id: z.uuid(), itemId: z.uuid() }) export const body = z.object({ after: z.uuid().optional() }) export default async ({ body, db, params, user }) => { await requireOwnedPlaylist(db, params.id, user.id) const above = body.after ? (await db.select().from(playlistItems).where(eq(playlistItems.id, body.after)))[0] : undefined const [below] = await db .select() .from(playlistItems) .where( and( eq(playlistItems.playlistId, params.id), above ? gt(playlistItems.position, above.position) : undefined, ), ) .orderBy(asc(playlistItems.position)) .limit(1) const position = between(above?.position, below?.position) if (collapsed(position, above?.position, below?.position)) { await rebalancePlaylist(db, params.id) // Retry once against fresh positions. return reorder(db, params) } const [moved] = await db .update(playlistItems) .set({ position }) .where(eq(playlistItems.id, params.itemId)) .returning() return moved } ``` One write, same as a card. The rebalance is the same too — and the same 42-drags-into-one-gap limit applies, though a playlist is reordered far less often than a sprint board, so it is unlikely to matter here. ## Watch Later, and other implicit playlists Watch Later and Liked Videos are playlists with a flag rather than special tables: ```ts kind: text('kind', { enum: ['user', 'watch-later', 'liked'] }).notNull().default('user'), ``` One `uniqueIndex` on `(channelId, kind)` for the non-`user` kinds keeps them singular. Everything above — ordering, visibility filtering, the add endpoint — works unchanged, which is the argument for not giving them their own tables. [Subscriptions and the feed →](https://theoven.app/docs/projects/youtube/subscriptions/) --- ## 6. Shipping it Source: https://theoven.app/docs/build/shipping/ > What production refuses to boot with, and why each refusal exists. ## Build ```bash bun run build ``` Two things happen: a route manifest is written with a static import per route file, so production never walks the filesystem at boot; then `Bun.build` bundles everything into a single `dist/index.js`. ```bash bun dist/index.js ``` That runs with **no `node_modules` at all** — dependencies are in the bundle. Which is what makes the container image below as small as it is. ## The Dockerfile ```dockerfile FROM oven/bun:1 AS build WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . RUN bunx oven build FROM oven/bun:1-slim WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/drizzle ./drizzle USER bun EXPOSE 3000 CMD ["bun", "dist/index.js"] ``` `oven/bun` is Bun's own image — the name collision with this framework is a coincidence, since `oven-sh` is Bun's GitHub organisation. The `drizzle/` folder comes along because migrations are how a fresh database gets its schema. Run them as a release step, not from every replica at boot: ```bash bunx oven db migrate ``` ## Four things that will refuse to start This is the part worth reading before you deploy rather than after. | | Refused in production | Because | | --- | --- | --- | | `mail` | the `console` driver | reset links printed to a log look healthy while nobody can reset their password | | `storage` | the `disk` driver | uploads on a container filesystem vanish on the next deploy | | `queue` | the `memory` driver | a deploy silently drops every queued job | | `cache` | the `memory` driver | each instance keeps its own copy, so a refresh gives a different answer | Your todo app registers none of the last three, so `mail` is the only one you have to deal with. It uses the console driver, so it will not boot with `NODE_ENV=production` until you configure a real one: ```ts title="src/mail.ts" export const driver = resendMail({ apiKey: config.resendApiKey, from: 'todos@example.com', }) ``` Not at deploy time — weeks later, as "password reset is broken" or "avatars keep disappearing". Every one of them looks perfectly healthy from the outside. That is exactly why they are boot errors rather than warnings, and each names itself and says what to configure. ## Configuration ```bash NODE_ENV=production PORT=3000 DATABASE_URL=/data/todo.db AUTH_SECRET= ``` `NODE_ENV=production` is what turns on the refusals above, and what stops internal error messages and stacks reaching clients. `AUTH_SECRET` has no default and never should. A SQLite file needs a mounted volume — a database inside the image resets on every deploy. When you outgrow it, [switching to Postgres](https://theoven.app/docs/bricks/db-drizzle/) is one import and one line in `db.ts`; every query you wrote in chapters 3 and 4 is unchanged. ## Health checks ```ts title="src/routes/health.get.ts" export default route({ summary: 'Liveness' }, async (ctx) => { const database = await checkHealth(ctx.db) ctx.status = database ? 200 : 503 return { status: database ? 'healthy' : 'degraded', database } }) ``` `checkHealth` issues a real query. A check that reports whether a pool *object* exists cannot fail, and a health check that cannot fail is not a health check. ## Shutdown is already handled `SIGTERM` stops accepting connections, waits for in-flight requests, drains the queue worker, then closes every brick's resources. Give your platform a grace period longer than your slowest request — 30 seconds is a reasonable default — or it will `SIGKILL` mid-drain and undo the point. ## Before you call it done 1. `bun run doctor` passes 2. `NODE_ENV=production` is set — several safety checks key on it 3. `AUTH_SECRET` comes from a secret store, not the image 4. Migrations run as a release step, not from every replica at boot 5. A volume is mounted for the SQLite file 6. The health check queries something real 7. The shutdown grace period exceeds your slowest request ## What you built Four route files, a schema, and a test — on top of a scaffold you did not modify. For that you have authentication with revocable sessions and password reset, per-user data isolation proven by tests, validation that documents itself, RFC 9457 errors with request ids, an OpenAPI document and a browsable reference, structured logging, and graceful shutdown. None of which you wired. ## Where to go next - **[Add file uploads](https://theoven.app/docs/bricks/storage/)** — attachments on a todo, streamed straight to S3 - **[Add background jobs](https://theoven.app/docs/bricks/queue/)** — a nightly digest of what is overdue - **[Send real email](https://theoven.app/docs/bricks/mail/)** — with typed templates - **[Write your own brick](https://theoven.app/docs/reference/bricks/)** — about fifty lines - **[Deployment guide](https://theoven.app/docs/guides/deployment/)** — Fly, Railway, and the full checklist --- ## YouTube: subscriptions and the feed Source: https://theoven.app/docs/projects/youtube/subscriptions/ > Subscribe, count subscribers, and build a home feed that is one query however many channels someone follows. ## Subscribing ```ts title="src/schema.ts" export const subscriptions = sqliteTable( 'subscriptions', { subscriberId: text('subscriber_id').notNull(), channelId: text('channel_id').notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .default(sql`(unixepoch() * 1000)`), }, // The pair is the identity. Subscribing twice is not two subscriptions, and the database // is what says so — verified: the second insert is refused by the primary key. (table) => [primaryKey({ columns: [table.subscriberId, table.channelId] })], ) ``` ```ts title="src/routes/channels/[handle]/subscribe.post.ts" export const auth = true export const params = z.object({ handle: z.string() }) export default async ({ db, params, user }) => { const [channel] = await db .select() .from(channels) .where(eq(channels.handle, params.handle.toLowerCase())) if (!channel) throw new NotFound('No such channel.') if (channel.ownerId === user.id) throw new Conflict('You cannot subscribe to your own channel.') await db .insert(subscriptions) .values({ subscriberId: user.id, channelId: channel.id }) .onConflictDoNothing() return { subscribed: true } } ``` Idempotent because the primary key makes it so — a double-tap on a phone is one subscription, with no read-then-write race. ## The feed, in one query Someone following 200 channels must not cost 200 queries. A subquery keeps it to one: ```ts title="src/routes/feed.get.ts" export const auth = true export const query = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), before: z.uuid().optional(), }) export default async (ctx) => { // Not fetched into memory — this becomes an IN (SELECT …) in the statement below. const following = ctx.db .select({ id: subscriptions.channelId }) .from(subscriptions) .where(eq(subscriptions.subscriberId, ctx.user.id)) const cursor = ctx.query.before ? (await ctx.db.select().from(videos).where(eq(videos.id, ctx.query.before)))[0] : undefined const rows = await ctx.db .select({ video: videos, handle: channels.handle, channelName: channels.name }) .from(videos) .innerJoin(channels, eq(channels.id, videos.channelId)) .where( and( inArray(videos.channelId, following), // The three conditions that keep unwatchable videos out of a feed. eq(videos.status, 'ready'), eq(videos.visibility, 'public'), cursor?.publishedAt ? lt(videos.publishedAt, cursor.publishedAt) : undefined, ), ) .orderBy(desc(videos.publishedAt)) .limit(ctx.query.limit) return { videos: rows.map((row) => ({ ...row.video, channel: { handle: row.handle, name: row.channelName } })), next: rows.length === ctx.query.limit ? (rows.at(-1)?.video.id ?? null) : null, } } ``` ### It was run Three channels, five videos, one viewer following two of them: ``` feed for u1 : alan public | grace public (excludes their own channel, a private video, and one still processing) ``` Two of five. The three excluded are exactly the three that should be — which is the whole point of putting `status` and `visibility` in the `WHERE` rather than filtering afterwards. Fetching 20 rows and then dropping the private ones leaves the reader with 17 and a `next` cursor that skips the rest. The conditions have to be in the statement so `LIMIT` counts things the viewer can actually see. ## Subscriber counts Counting on read is right until it isn't: ```ts const [{ subscribers }] = await db .select({ subscribers: count() }) .from(subscriptions) .where(eq(subscriptions.channelId, channel.id)) ``` Verified against the seeded data: ``` subscriber counts: [{"channelId":"c2","subscribers":1},{"channelId":"c3","subscribers":1}] ``` With an index on `subscriptions(channel_id)` this is fine into the millions. It stops being fine when the count is on a page served constantly — a channel page, and every video card in the feed. The fix, when you get there, is a counter column on `channels` updated in the same transaction as the subscribe, plus a periodic reconciliation job because counters drift. Do not start there: a denormalised count is a second source of truth, and it will be wrong at some point in a way the `COUNT(*)` never is. ## Notifying subscribers When a video goes public, everyone following should hear about it. Fan-out belongs in a job, not in the request: ```ts title="src/jobs.ts" export const notifySubscribers = defineJob<{ videoId: string }>({ name: 'notify-subscribers', retries: 3, handler: async ({ payload, log }) => { const [video] = await db.select().from(videos).where(eq(videos.id, payload.videoId)) if (!video || video.visibility !== 'public') return const followers = await db .select({ userId: subscriptions.subscriberId }) .from(subscriptions) .where(eq(subscriptions.channelId, video.channelId)) for (const follower of followers) { app.publish(`user:${follower.userId}`, JSON.stringify({ type: 'video.published', video })) } log.info('notified subscribers', { videoId: video.id, count: followers.length }) }, }) ``` Dispatched from the publish endpoint. A channel with a hundred thousand subscribers must not make someone's PATCH request take a minute — and it must not fail their edit because the notification step broke. The `visibility !== 'public'` re-check inside the job matters: between dispatch and execution the owner may have made it private again, and a job that trusts its payload would notify everyone about a video they cannot watch. ## What you built | | | | --- | --- | | Channels | unique handles, decided by the index rather than a racy lookup | | Uploads | a real pipeline, with a `failed` state the owner can see | | Visibility | one rule, four routes, `404` rather than `403` | | Views | atomic, deduplicated per viewer per hour, off the read path | | Playlists | ordered, and filtered per viewer — a public playlist does not publish private videos | | Subscriptions | idempotent, and a feed that is one query | ## What this does not do - **No transcoding.** `transcode()` is where ffmpeg or a service goes; the pipeline around it is what these chapters build. - **No adaptive streaming.** One source file, no HLS or DASH ladder, and no range requests on the development path — so seeking needs the presigned branch. - **No comments, likes or search.** Comments are the chat schema; search wants Postgres full-text or a separate index. - **The feed is chronological.** No ranking, which is a system rather than a query. - **Subscriber counts are computed**, deliberately — see above for when to change that and why not to start there. --- ## Oven on Bun 1.4 Source: https://theoven.app/docs/blog/oven-on-bun-1-4/ > We moved the whole monorepo to Bun 1.4, shipped an image brick built on Bun.Image, and re-ran the benchmarks — including the one that now says something we would rather it did not. *21 August 2026* [Bun 1.4](https://bun.com/blog/bun-v1.4) landed yesterday. We have moved every package to it, shipped a new brick on the back of it, and re-run the benchmarks. One of those benchmarks now contradicts something this site used to claim, so let us start there. ## We were beating Hono on dispatch. We are not any more. Our [benchmarks page](https://theoven.app/docs/guides/benchmarks/) has always published two tables: dispatch overhead (`Request` in, `Response` out, no socket) and real HTTP throughput. On Bun 1.2.23, Oven dispatched faster than Hono on a parameterised route and on a 404, and lost on static routes. On Bun 1.4: | scenario | oven | hono | previously: oven | hono | | --- | ---: | ---: | ---: | ---: | | static root | 1051 ns | 406 ns | 1258 ns | 1086 ns | | param + json | 1255 ns | 916 ns | 1519 ns | 1838 ns | | deep static | 1176 ns | 477 ns | 1423 ns | 1193 ns | | 404 miss | 1228 ns | 956 ns | 1530 ns | 1922 ns | Oven got about 17% faster. Hono got **2.0–2.7×** faster, and now leads every scenario. The likely cause is in Bun's own release notes: 1.4 rewrote the RegExp engine, with figures like 138× on `marked.parse()`. Hono's router is regular-expression driven. Ours walks a radix tree by hand, character by character, which is why a faster RegExp engine does comparatively little for us. A runtime improvement that lands squarely on a competitor's hot path is still a real result, and we are not going to write it up as an asterisk. **What did not change is the table that describes a deployment.** At the socket: | framework | runtime | static text req/s | param + json req/s | | --- | --- | ---: | ---: | | hono | bun | 94,765 | 95,911 | | **oven** | **bun** | **93,805** | **94,087** | | fastify | node | 80,084 | 77,824 | | express | node | 18,155 | 18,072 | A 2.6× dispatch advantage becomes a **1.0%** socket advantage. Once a real connection exists, syscalls and TCP dominate and a few hundred nanoseconds of framework overhead compresses into nothing. That was the lesson of publishing both tables when the microbenchmark flattered us, and it is the same lesson now that it does not. Every row of the socket table is below the 1.2.23 run. Express and Fastify did not change runtime — same Node 22.22, same code — and moved −1.7% and −3.8%, which sets the floor for how much this machine drifts between sessions. Both Bun frameworks fell roughly three times further, and fell together. One laptop running its own load generator is not evidence of a Bun regression, and we are not going to quietly restate the numbers as though nothing happened either. The ranking is unchanged and the Oven-to-Hono gap is unchanged. If you have a two-machine setup, we would genuinely like the help. ## A new brick: `image` Our own front page has always used `resizeAvatar` as the canonical example of a queue job. Until this week, **Oven had no way to resize an image.** You installed `sharp`, or you proxied the work to a CDN. For a framework whose entire pitch is "you should never wire infrastructure by hand again", that is a promise we were not keeping. `Bun.Image` closes it, with no native module to compile and nothing to install: ```ts app.use(image({ format: 'webp' })) ``` ```ts const avatar = await ctx.image.transform(file, { width: 256, height: 256 }) await ctx.storage.upload(`avatars/${ctx.user.id}.webp`, avatar.bytes) ``` Resize, rotate, flip, convert between formats, ThumbHash placeholders for blur-up loading, and `variants()` for a `srcset` ladder. [The full page is here.](https://theoven.app/docs/bricks/image/) ### The part that made it worth being a brick If the brick were only a thin wrapper over `Bun.Image`, we would have written a recipe instead. What justifies it is the thing that is easy to leave out. A 4000×4000 PNG of flat colour compresses to about **70 KB**. Nothing about that request looks unusual. Decoding it allocates 16 megapixels, and a handful arriving together will end a small server. This is the classic decompression bomb, and a byte limit cannot see it coming. The defence is to read the header first — and the reason the defence is practical is the ratio: | source | on the wire | header read | resize + encode | | --- | ---: | ---: | ---: | | 800×600 | 3 KB | 0.8 µs | 3.8 ms | | 2000×1500 | 15 KB | 0.4 µs | 7.7 ms | | 4000×4000 | 70 KB | 0.6 µs | 44.4 ms | Header reads are constant time. Decoding is linear in pixels. **The bigger the attack, the wider the margin.** Every method in the brick checks dimensions against your limits before anything is decoded, and an oversized upload comes back as a `413` problem document. It also refuses to upscale unless asked. `Bun.Image` will happily render a 200 px upload at 8000 px if a request says so. ### The portability trap we nearly shipped `Bun.Image` runs on two backends: `system` on macOS and Windows, using the OS codecs, and `bun` everywhere else — including **every Linux server**. | format | `system` | `bun` (Linux) | | --- | --- | --- | | JPEG, PNG, WebP | yes | yes — byte-identical | | AVIF, HEIC | yes | **no** | AVIF encodes perfectly on a Mac and throws `ERR_IMAGE_FORMAT_UNSUPPORTED` on the server. A developer would configure it, see it work, ship it, and find out from users. So the brick encodes a single pixel at **boot** and refuses to start if the configured format cannot be written here, naming the format, the backend and the fix. And because the three portable formats are byte-identical across backends — verified buffer-to-buffer in the test suite, not assumed — you can set `backend: 'bun'` in development and get exactly what production will produce. We found this by reading Bun's type declarations rather than by testing on Linux, and then reproduced it on macOS by pinning the backend. That trick is also how the test suite covers it. ## Everything else in the upgrade The whole monorepo now declares `"bun": ">=1.4.0"` — 25 packages. All **1656 existing tests passed unchanged** on the new runtime before a line of new code was written, which is the main thing we wanted to know. With the image brick's own 24, the suite is now 1680. A few 1.4 features are relevant to what we are building next, and we are not rushing them: - **`Bun.Terminal` and `spawn({ cgroup })`** arrive right before `@theoven/sandbox` was due, and change how we would build its local driver. Worth a design pass first. - **`bun test --shard` / `--changed` / `--retry`** are real CI wins on 48 test files plus a Docker integration job. - **`Bun.serve` static directory routes** with ETags and Range support — tempting for core, but plenty of APIs serve no files at all, so it needs an argument rather than an impulse. - **`Bun.cron()`** overlaps `@theoven/queue` less than it appears. Ours is in-process with retries and a dead-letter queue; Bun's interesting mode registers with the OS scheduler and survives the process dying. Different tools. One thing we checked and did not change: `URLPattern` is now native in Bun with 408 Web Platform Tests passing. Our own radix router stays, for the reason it was chosen in the first place — a third-party matcher's shape leaks into the Context, permanently. ## Try it ```bash bun create theoven my-app ``` Everything above is reproducible: ```bash git clone https://github.com/hiteshchoudhary/theoven.git cd theoven && bun install bun benchmarks/http.bench.ts bun benchmarks/image.bench.ts ``` If your numbers disagree with ours, that is worth an issue — particularly the socket table. --- ## Oven documentation Source: https://theoven.app/docs/ > The batteries-included Bun framework — guides, tutorial and API reference. Oven is a backend framework for Bun. It replaces Express, takes its cues from FastAPI, and is TypeScript-native end to end. The promise: **you should never wire infrastructure by hand again.** Auth, a database, file storage, email and background jobs are configuration, not a week of plumbing. ```bash bun create theoven my-app --db sqlite --auth basic ``` That gives you a working app with a database, signup, login and password reset before you have provisioned anything at all. ## Where to start Pick the one that matches why you are here. | | | | --- | --- | | **New to Oven** | [Installation](https://theoven.app/docs/start/installation/) → [Tutorial](https://theoven.app/docs/tutorial/first-route/) — five chapters, from one route to a database and auth | | **Learning by building** | [Build a todo API](https://theoven.app/docs/build/) — one project, end to end: routes, database, auth, tests, deploy | | **Adding one feature** | [Recipes](https://theoven.app/docs/recipes/) — sign in with Google, image uploads, share links, real-time | | **Building something real** | [Projects](https://theoven.app/docs/projects/) — a chat backend, a Trello clone, a video platform | | **Coming from Express** | [The translation guide](https://theoven.app/docs/guides/coming-from-express/) — what to delete, what to rewrite, in what order | | **Deciding whether to use it** | [Batteries included](https://theoven.app/docs/start/batteries/) · [Philosophy](https://theoven.app/docs/start/philosophy/) · [Benchmarks](https://theoven.app/docs/guides/benchmarks/) | | **Adding a capability** | [The brick catalogue](https://theoven.app/docs/bricks/) — database, auth, storage, mail, queues | | **Looking something up** | [Context](https://theoven.app/docs/reference/context/) · [App](https://theoven.app/docs/reference/app/) · [CLI](https://theoven.app/docs/reference/cli/) | ## The shape of an app ```ts title="src/app.ts" export const app = createApp() .use(db(drizzleSqlite({ url: './data.db', schema }))) .use(storage(s3Storage({ bucket: 'uploads' }))) .use(image({ format: 'webp' })) .use(queue(redisQueue(), { jobs: [buildVariants] })) .use(auth(basicAuth({ db: client, secret }))) ``` ```ts title="src/routes/users/[id]/avatar.post.ts" export default route( { auth: true, body: z.object({ file: z.file().max(5_000_000) }), response: { 200: z.object({ id: z.uuid(), avatar: z.string() }) }, }, async (ctx) => { const avatar = await ctx.image.transform(ctx.body.file, { width: 256, height: 256 }) const { key } = await ctx.storage.upload(`avatars/${ctx.params.id}.webp`, avatar.bytes) await ctx.queue.dispatch(buildVariants, { key }) return ctx.db.update(users).set({ avatar: key }).returning() }, ) ``` That route is validated, authenticated, typed and documented at `/docs` — and nothing in it was wired by hand. Leave a brick out of `app.ts` and touching it is a compile error rather than a crash at 3am. The `response` schema is not only documentation: it **filters the body**, so the row's `passwordHash` cannot be sent even though the handler returned the whole record. ## Grouping and per-request values ```ts title="src/routes/admin.ts" const admin = routerFor({ prefix: '/admin', tags: ['admin'], auth: 'staff' }) admin.get('/users', (ctx) => ctx.db.select().from(users)) admin.delete('/users/:id', { params: idParam }, (ctx) => remove(ctx.params.id)) app.use(admin) // prefix, tag and guard declared once ``` ```ts title="a transaction that rolls itself back" const tx = dependency('tx', async function* (ctx) { const handle = await begin(ctx.db) try { yield handle await handle.commit() } catch (error) { await handle.rollback() throw error } }) export default route({ deps: { tx } }, async (ctx) => { await ctx.deps.tx.insert(orders).values(order) return { ok: true } }) ``` [Routers](https://theoven.app/docs/reference/routers/) group routes; [dependencies](https://theoven.app/docs/reference/dependencies/) are per-request values that compose, cache within a request, and clean up after it. ## Reference | | | | --- | --- | | [Context](https://theoven.app/docs/reference/context/) | everything on `ctx`, the argument every handler receives | | [App](https://theoven.app/docs/reference/app/) | `createApp`, routing, configuration, lifecycle | | [File-based routing](https://theoven.app/docs/reference/file-routing/) | the filesystem is the route table | | [Routers](https://theoven.app/docs/reference/routers/) | mountable route groups with shared prefix, tags and auth | | [Dependencies](https://theoven.app/docs/reference/dependencies/) | per-request values, composed, with teardown | | [Validation](https://theoven.app/docs/reference/validation/) | Standard Schema, uploads, response schemas | | [Errors](https://theoven.app/docs/reference/errors/) | the classes, and the RFC 9457 shape they become | | [Middleware & hooks](https://theoven.app/docs/reference/middleware/) | the onion model, five hooks, built-ins | | [WebSockets & SSE](https://theoven.app/docs/reference/realtime/) | real-time, upgraded from a guarded route | | [Always-on batteries](https://theoven.app/docs/reference/batteries/) | body, cookies, uploads, tokens, query | | [Bricks](https://theoven.app/docs/reference/bricks/) | the contract, and how to write your own | | [Environment variables](https://theoven.app/docs/reference/env/) | readers that throw instead of guessing | | [CLI](https://theoven.app/docs/reference/cli/) | `create`, `dev`, `db`, `worker`, `doctor` | | [The public API](https://theoven.app/docs/reference/public-api/) | what each package exports, and what is deliberately internal | ## Status **0.6.1, on npm.** Everything documented here is published and installable: ```bash bun create theoven my-app --db sqlite --auth basic ``` Core, the CLI, and twenty-two bricks — database (Drizzle, Mongoose), auth (email/password on SQL or Mongo, Clerk, better-auth), storage (S3, disk, Bunny, ImageKit), mail, queues, cache, OpenTelemetry, webhooks, rate limiting, vector search (embedded, pgvector, Qdrant), AI and images. See the [brick catalogue](https://theoven.app/docs/bricks/) for what each one does, and the [roadmap](https://github.com/hiteshchoudhary/theoven/blob/main/TODO.md) for what is next, and the [changelog](https://github.com/hiteshchoudhary/theoven/blob/main/CHANGELOG.md) for what each release changed. Every package now declares `"bun": ">=1.4.0"`. Nothing changed for existing code — the whole suite passed unchanged on the new runtime — but an older Bun will refuse to install. The bump is what lets [`image`](https://theoven.app/docs/bricks/image/) use `Bun.Image` rather than a native module. **From 0.1.2 or earlier — do it.** A route file declaring `export const auth = true` was **not guarded**: the declaration was dropped on the way to the auth brick and the route answered anonymously with a `200`. `defineRoute({ auth: true }, …)` was unaffected. Fixed in 0.1.3. **To 0.2.0**, two breaking changes: - A `response` schema now **filters the body** rather than only checking it. Fields the schema does not declare are no longer sent. Add them, or set `serializeResponses: false`. - The radix tree exported as `Router` is now `RadixRouter`; `Router` names the [route group](https://theoven.app/docs/reference/routers/) you build. Almost nobody imports the former. **To 0.3.0**, one: `/_oven/*` — the mail preview inbox and the queue dashboard — no longer appears in the generated OpenAPI document. It is still served; it was never meant to be in a client generated from your API. Pre-1.0, so APIs can still change between minor versions — that freedom is deliberate while the contracts settle. Every brick page lists what it **cannot** do under Limitations, because a page that only lists capabilities is an advertisement. ## Reading this with a coding agent The whole documentation set is published in the [llms.txt](https://llmstxt.org) format, generated from these pages at build time so it cannot drift from them: | File | What it is | | --- | --- | | [`/llms.txt`](/llms.txt) | an index — every page, its one-line description, its URL | | [`/llms-full.txt`](/llms-full.txt) | the full text of every page, so no further fetches are needed | `oven create` also writes an **`AGENTS.md`** into every project, describing the conventions that matter and the Express habits that do not apply here.