Always-on batteries
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.
ctx.body is awaited, and parsed from the request’s Content-Type.
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
Section titled “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.
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
Section titled “Limits”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.
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.
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
Section titled “Cookies”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
Section titled “Signed cookies”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 withSignatures 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.
Token capture
Section titled “Token capture”ctx.token is populated on every request, with or without an auth module installed.
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.
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.
// ?tag=a&tag=b&filter[status]=open&page=2ctx.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
Section titled “Environment variables”import { env } from '@theoven/core'
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.
Headers, IP and negotiation
Section titled “Headers, IP and negotiation”ctx.header('x-api-version') // undefined when absent, never nullctx.accepts('application/json', 'text/html')ctx.ipctx.ip uses the socket address. It honours X-Forwarded-For only when you configure
trustProxy:
createApp({ trustProxy: 1 }) // one proxy hop in frontThis 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.