API surface
One NestJS service, five controllers, and a guard chain that runs before all of them. Authentication is the default; @Public() is the exception you have to write down.
What runs before your handler
Four global guards, in registration order — the order is the argument. Throttle first, so an unauthenticated flood is dropped before it costs a JWKS lookup. Then authenticate the token. Then resolve the token subject into a user row with its roles. Only then authorize.
- 1ThrottlerGuard60 req / 60s, tighter per route
- 2ClerkJwtGuardverifies against Clerk JWKS
- 3UserResolutionGuardsubject → user id + roles
- 4RolesGuardreads @Roles, DB-backed
@Public() skips authentication, not throttling. @AuthSubject() injects the Clerk subject, @CurrentUserId() the resolved row id — a handler that takes the second one cannot run before guard three.
/transactions
Every route is scoped to the caller. Categorization is per-user - the same merchant can mean different things to two people - so even the read-only resolve endpoints need an identity.
- GET /transactions — List, filtered by the query DTO bearer
- POST /transactions — Create one; resolves the merchant inline bearer
- POST /transactions/batch — Create many - a statement import bearer
- POST /transactions/categorize — Resolve one descriptor, write nothing bearer
- POST /transactions/categorize/batch — Resolve many; the import screen calls this first bearer
- GET /transactions/:id — Declared after the static routes, or categorize is captured as an id bearer
- PATCH /transactions/:id — Edit; a category change is the signal that teaches the dictionary bearer
- DELETE /transactions/:id — Soft delete bearer
- POST /transactions/:id/repeat — Clone onto a new occurredAt bearer
/onboardings
The only public surface of consequence. An onboarding exists before an account does, so the session is held by a durable secret in x-onboarding-secret rather than a JWT - and the write routes are rate-limited harder than the global default.
- GET /onboardings/goal-types — Seeded lookup public
- GET /onboardings/intent-types — Seeded lookup public
- POST /onboardings — Start a session; returns the onboarding secret public · 5/min
- PATCH /onboardings/:id/progress — Advance one step session secret
- POST /onboardings/:id/convert — Attach the finished session to the account that just signed up bearer + secret · 10/min
/budget
A cycle is one run of the budget algorithm, stored with the inputs it ran on. The simulator is public because it computes from the body alone and touches nothing.
- POST /budget/cycles — Run and persist a cycle for a month bearer
- POST /budget/cycles/simulate — Run against a supplied body, persist nothing public
- GET /budget/cycles/current — Literal route before :id, or "current" matches as an id bearer
- GET /budget/cycles/:id — One cycle in full bearer
- GET /budget/cycles — Summaries, newest first bearer
/users
Roles are read from the database, not from the token, so a role change takes effect on the next request rather than the next login.
- GET /users — Staff list INTERNAL_ADMIN | INTERNAL_STAFF
- GET /users/me — The caller bearer
- PATCH /users/me — Update own profile bearer
/webhooks
Public to the network, closed by signature: the raw body is verified against CLERK_WEBHOOK_SIGNING_SECRET with the svix headers before anything is read from it. Nest is bootstrapped with rawBody: true for exactly this.
- POST /webhooks/clerk — user.created / updated / deleted svix signature
Bootstrap facts worth knowing
- Docs
- /api
- Port
- PORT → 3000
- Validation
- AppValidationPipe, global
- Errors
- HttpExceptionFilter, global
- CORS origins
- localhost:8081, :19006
- Extra headers
- x-onboarding-secret, x-idempotency-key
.env rather than assuming localhost.
Checking this page. Every row above corresponds to a decorator in the file named in its panel bar. The live contract is the Swagger document at /api; this page exists to be readable, not to replace it.