DEV Community

Ahmad Karawash
Ahmad Karawash

Posted on

Amoxi: Validation, sessions and security you get for free

Validate request bodies straight from TypeScript types, remember visitors with encrypted sessions, and see the protections that are on before you write a line.

Every web app ends up writing the same defensive code: check the request body, reject the bad fields, keep track of who is who, block cross-site requests, set the security headers. In Amoxi most of it comes from code you already wrote, your TypeScript types, or is simply on by default.

We will build a small sign-up API for a workshop and attack it a little.

Your type is the validator

Create src/events/event.ts:

export interface NewSignup {
  /**
   * @minLength 1
   * @maxLength 60
   * @pattern \S
   * @patternMessage must not be blank
   */
  readonly name: string;
  /** @format email */
  readonly email: string;
  /** @integer @minimum 1 @maximum 4 */
  readonly seats: number;
  readonly diet?: 'none' | 'vegetarian' | 'vegan';
}

export interface Signup extends NewSignup {
  readonly id: number;
}
Enter fullscreen mode Exit fullscreen mode

This is an ordinary interface. The JSDoc tags add the rules a type cannot express: lengths, patterns, formats (email, url, uuid, date, date-time), number ranges and whole numbers. No schema library, no second definition to keep in sync.

Now the controller, src/events/signup-controller.ts:

import { Body, Controller, HttpResponse, Post } from 'amoxi';
import type { NewSignup, Signup } from './event';

@Controller('/api/workshop')
export class SignupController {
  private readonly signups: Signup[] = [];

  @Post('/signups')
  signUp(@Body() body: NewSignup): HttpResponse<Signup> {
    const signup = { ...body, id: this.signups.length + 1 };
    this.signups.push(signup);
    return HttpResponse.created(signup);
  }
}
Enter fullscreen mode Exit fullscreen mode

@Body() body: NewSignup is all it takes. At build time Amoxi reads the type and generates a validator for it. By the time signUp runs, body is guaranteed to match.

Send it garbage

The examples use curl. On Windows PowerShell, type curl.exe instead of curl, or send the same requests from a REST client in your editor.

A blank name, a broken email and too many seats:

curl -X POST -H "content-type: application/json" -d '{"name":"  ","email":"sam@","seats":7}' http://localhost:8080/api/workshop/signups
Enter fullscreen mode Exit fullscreen mode
{"error":"Invalid request body","details":[
  {"path":"name","message":"must not be blank"},
  {"path":"email","message":"must be a valid email"},
  {"path":"seats","message":"must be ≤ 4"}]}
Enter fullscreen mode Exit fullscreen mode

The answer is a 400 listing every problem at once, not just the first, so a form can highlight all the fields together. Wrong types are caught the same way:

{"error":"Invalid request body","details":[
  {"path":"seats","message":"must be a number (got string)"},
  {"path":"diet","message":"must be one of \"none\", \"vegetarian\", \"vegan\""}]}
Enter fullscreen mode Exit fullscreen mode

Now a valid request that tries to sneak in extra fields:

curl -X POST -H "content-type: application/json" -d '{"name":"Sam Rivera","email":"sam@example.com","seats":2,"isAdmin":true,"id":99}' http://localhost:8080/api/workshop/signups
Enter fullscreen mode Exit fullscreen mode
{"name":"Sam Rivera","email":"sam@example.com","seats":2,"id":1}
Enter fullscreen mode Exit fullscreen mode

isAdmin and the forged id are gone. Your method receives a clean copy that contains only the properties the type declares. This is the classic mass-assignment hole, closed by default.

Mistakes in the rules themselves are caught too: write @format mail and the build stops, listing the formats that exist.

Sessions without setup

Let the server remember who signed up. Add a RequestContext parameter and use ctx.session:

import { Get, type RequestContext } from 'amoxi';

// in signUp, after saving:
ctx.session.set('signupId', signup.id);

@Get('/me')
me(ctx: RequestContext): Signup | HttpResponse<{ error: string }> {
  const id = ctx.session.get<number>('signupId');
  const mine = this.signups.find((s) => s.id === id);
  return mine ?? HttpResponse.notFound('You have not signed up yet');
}
Enter fullscreen mode Exit fullscreen mode

Calling /api/workshop/me with the cookie from the sign-up returns Sam's record; without it, a 404.

The session lives in a cookie called amoxi_session, encrypted with AES-256-GCM. It is HttpOnly (scripts cannot read it), SameSite=Lax, and Secure over HTTPS. There is no session store to run and nothing to configure: amoxi build generates the key, so sessions work across restarts and across every worker.

Two calls matter for logins:

ctx.session.regenerate(); // on sign-in: keeps the data, issues a new session id
ctx.session.destroy();    // on sign-out
Enter fullscreen mode Exit fullscreen mode

The key is created once per checkout, in .amoxi/session.key (git-ignored), so rebuilding on your laptop keeps you signed in. A fresh checkout, such as a CI job or a Docker build, gets a new key, which signs everyone out on deploy. To keep people signed in across deploys, set AMOXI_SESSION_SECRET to the same long random value on every server. Article 7 comes back to this. Keep sessions small: a cookie holds about 4 KB, so store ids, not whole records.

Cross-site requests are blocked

What if another website tries to sign your visitor up, using their cookie? Simulate it with a foreign Origin header:

curl -b cookies.txt -X POST -H "content-type: application/json" -H "Origin: https://elsewhere.example" -d '{"name":"Eve","email":"e@example.com","seats":1}' http://localhost:8080/api/workshop/signups
Enter fullscreen mode Exit fullscreen mode
{"error":"Cross-site request blocked (CSRF protection)"}
Enter fullscreen mode Exit fullscreen mode

That is a 403. Any POST, PUT, PATCH or DELETE that carries cookies must come from your own origin. There are no CSRF tokens to thread through your forms.

The rest is already on

Look at the headers of any page:

content-security-policy: default-src 'self'; script-src 'self' 'nonce-jUf/FrOX1OMuQ1QinUu4AA=='; ...
x-frame-options: DENY
Enter fullscreen mode Exit fullscreen mode

The Content Security Policy gets a new nonce on every request, and Amoxi adds it to the scripts it renders. A script injected into your HTML has no nonce, so the browser refuses to run it. Also on by default:

  • X-Content-Type-Options, Referrer-Policy, Cross-Origin-Opener-Policy, Permissions-Policy, and HSTS in production over HTTPS.
  • A body size limit, JSON-only request bodies, and timeouts for slow clients.
  • Static files that cannot escape the public folder or serve dotfiles.
  • Production errors without stack traces, but with a request id you can find in the logs.
  • Malformed input answered with a 400, never a crash.

Caching that is safe by default

The workshop has a public summary that every visitor sees the same. Cache it:

import { Cached, revalidatePath } from 'amoxi';

@Get('/summary')
@Cached(60, { staleWhileRevalidate: 300 })
summary() {
  const seats = this.signups.reduce((n, s) => n + s.seats, 0);
  return { signups: this.signups.length, seats, at: new Date().toISOString() };
}
Enter fullscreen mode Exit fullscreen mode

For 60 seconds the response is served from memory. After that, for up to 300 more seconds, the cached copy is still served while a fresh one renders in the background, so no visitor waits. If you have used Incremental Static Regeneration in Next.js, it is the same idea. The response also tells CDNs what to do:

cache-control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
Enter fullscreen mode Exit fullscreen mode

When the data changes, do not wait for the timer. In signUp, add:

revalidatePath('/api/workshop/summary');
Enter fullscreen mode Exit fullscreen mode

The next request gets fresh numbers.

Caching has one classic trap: storing a page that was personal to one visitor and serving it to the next. Amoxi refuses to do that. A cached route that reads the session or cookies, or sets a cookie, is never stored, and it is sent with Cache-Control: private, no-store so a CDN will not store it either.

Recap

  • A TypeScript interface plus a few JSDoc tags is a complete validator.
  • Every problem is reported at once, and undeclared fields are stripped.
  • ctx.session is an encrypted cookie that needs no setup.
  • CSRF protection, a nonce-based CSP and the usual security headers are on by default.
  • @Cached and revalidatePath give stale-while-revalidate caching that will not leak personal pages.

Next

Article 5 starts the AI part of the series: an assistant that answers customers' questions by calling your own TypeScript methods as tools.

Amoxi is open source under the Apache License 2.0. Code: github.com/karawash-projects/amoxi. Documentation: karawash-projects.github.io/amoxi.

Top comments (0)