saas-api Walkthrough
examples/hello-api shows the basics. examples/saas-api shows what is left once you want to put something on the internet: accounts, tenants, abuse limits, health checks, migrations, logs you can follow. It is deliberately small: every part is there because the framework makes it work, and the README has the commands to run it and a table of where each thing is. This page is the reading order.
1. Start at the middleware, src/app.ts
Section titled “1. Start at the middleware, src/app.ts”The order of the middleware array is the design:
health.middlewareanswers/livezand/readyzbefore anything else, so a load balancer’s probes are not logged or rate-limited.requestId()andaccessLog(): every response, a404and a429included, carries an id and gets a log line.onErrorlogs unexpected errors with the same id, so a user’s “it failed, idabc” finds the line.cors()andsecurityHeaders()sit outside the limiters, so even a rejected request carries them. Without CORS headers a browser hides the real status from the front end.- Two
rateLimit()s: ten sign-in, sign-up and refresh attempts a minute per client (that is where passwords are guessed, and it refuses rather than allows if the limiter’s database is down), and a looser one for everything else. The counters live in the application’s own database, so the limit holds across replicas. The client is read from the end ofX-Forwarded-For, counting the proxies you say you run.
Concepts: Middleware, Securing the API, Logging requests and errors.
2. Accounts, src/auth/
Section titled “2. Accounts, src/auth/”protectAllRoutes: true: every route needs a token unless it says@Public(). The public routes also say@ApiSecurity(false), because the guard and the OpenAPI document are two different things to keep in step.- Sign-up is one transaction (
@Transactional()): the user, their organization, a first space and the membership either all exist or none do. A taken email rolls back and answers409. - Refresh tokens use the Drizzle store with an atomic
rotate()and families: replaying one device’s old token ends only that login. See Issuing tokens.
3. Tenants, src/tenancy/ and src/projects/
Section titled “3. Tenants, src/tenancy/ and src/projects/”TenantScopedGuard checks the caller belongs to the :spaceId in the URL and answers 404, never 403, to a non-member. The service then puts tenantScope() in every query, takes the tenant as an argument (so a background job can call it with a tenant it builds), takes the tenant columns of a new row from the guard and never the body, and treats an id that is not a uuid as a 404. The schema backs it up: a task’s composite foreign key (project_id, space_id) means the database refuses a task whose project is in another space. The tests attack each of these. See Tenancy.
Events that must not be lost, src/outbox/
Section titled “Events that must not be lost, src/outbox/”Creating a project also writes a project.created row to saas.outbox in the same transaction; a relay delivers it to a consumer that writes the space’s activity feed (GET /spaces/:spaceId/activity). Read The transactional outbox for the guarantees and the trade-offs.
API keys for machines, src/api-keys/
Section titled “API keys for machines, src/api-keys/”A space’s owners create keys (POST /spaces/:spaceId/api-keys); the response is the only time the key is shown. A key carries scopes, an optional expiry and optional allowedCidrs, acts in one space (the tenancy guard answers 404 for any other), and can reach only routes that say which scope they need (scopedRoutesOnly), so /me and key management are closed to it. The limit per key is counted after the key is verified: see key-rate-limit.guard.ts for why it cannot be a middleware keyed on the header. API keys reference.
4. Operations
Section titled “4. Operations”- Migrations are plain SQL files applied by
blix run db:migrate, each in a transaction, under an advisory lock. Run it once per release, before the new version starts. - Health: the provider that owns the database registers the readiness check. On SIGTERM,
main.tsstarts draining (/readyzanswers503), waits for the balancer to notice, then closes. - Configuration refuses to boot half-configured: no default for
DATABASE_URL, aJWT_SECRETof at least 32 bytes.
What it proves, and what it does not
Section titled “What it proves, and what it does not”45 tests against a real Postgres, nothing mocked: the happy path, every denied case (a non-member, another organization’s id through your own space, a forged tenant in a body, a user in two spaces, the 429), one rate limit across two instances, request ids tying logs to responses, readiness flipping on drain, and the OpenAPI document. It has no invitations, password reset or email verification, runs one application per process, and was not run behind a real proxy or built into a Docker image. The README lists these.