backend
Backend patterns for tereo — the one Express/Prisma service (auth, files, webhooks, audit). Use when creating or changing routes, controllers, Zod validation, error handling, db functions, Prisma mode
SKILL.md.claude/skills/backend/SKILL.md
GitHubBackend Patterns
tereo is one service (packages/tereo, port 4001) with one database — a
Postgres schema per area (auth, files, webhooks, audit, plus core for the outbox, keys and
registry). Areas share one Express app, one db object and one env schema; nothing calls another area
over HTTP.
Package Layout
packages/tereo/
├── prisma/schema.prisma # The one schema; client generated to prisma/generated/client
├── openapi/{auth,files,webhooks,audit}/
└── src/
├── index.ts / app.ts / worker.ts # Entry, Express app, background work (ROLE=all|api|worker)
├── preload.ts # First import of every entry point (loads .env)
├── env.ts # The one Zod env schema — `import env from "./env.js"`
├── definitions/ # Core's declaration: permissions, roles, buckets, event types
├── middleware/ # auth.ts, organizationId.ts, rateLimit.ts, accessLog.ts, …
├── routes/ # Mirrors the URL (see below)
│ └── v1/{area}/…/{resource}/
│ ├── index.ts # The one router: imports and route wiring only
│ ├── controllers.ts # Handlers
│ ├── schemas.ts # Zod schemas
│ ├── middleware.ts # Route-local middleware (guards, multer wrappers), if any
│ └── {item}/ # The `/:id` sub-router (organization/, webhook/, …)
├── utils/
│ ├── db/{table}.ts # Prisma calls, one file per table group; db/index.ts = `db`
│ ├── outbox.ts # logAuditTx, emitTx, computeChanges, the outbox reaper
│ └── cursor.ts # applyCursorToWhere, buildCursorPageResponse
├── jobs/ # BullMQ queues and workers
└── tests/ # Mirrors src/ (see the `test` skill and tests/README.md)
URL → folder: routes/index.ts is the root of the tree. It mounts /health (routes/health/),
core's own API under /v1 (routes/v1/, composed in routes/v1/index.ts with each area's
body-size limit: v1/account/, v1/admin/, v1/setup/, v1/files/, v1/webhooks/, v1/audit/), the protocol endpoints at the root (routes/oauth/, routes/well-known/), and
last the admin UI, which takes every browser path outside the reserved prefixes in utils/ui.ts.
A new API route goes under routes/v1/.
Route Layout and Registration Order
Where a route's files go, what each file holds, :param folders and the order routes register in
are in the routes skill (enforced by tests/conventions/routeLayout.unit.test.ts). REQUIRED: load it before
adding or moving a route.
Validation
- Always Zod.
validateBody(schema, req),validateParams(schema, req),validateQuery(schema, req)fromutils/validateReq.ts— they throwValidationError(400invalid_request). - Ids are UUIDv7: param schemas use
z.guid()— any 8-4-4-4-12 hex id, as Postgres takes it (z.uuid()refuses ids outside RFC 9562's variants). - List endpoints are cursor-paginated:
createCursorQuerySchema({ filters, sortFields, defaultSort })fromutils/parseQueryString.ts, thenapplyCursorToWhere(query, baseWhere)andbuildCursorPageResponse(result, query)fromutils/cursor.ts(cursors are HMAC-signed withCURSOR_SIGNING_KEY_V{n}). There is no offset pagination. - Extra query flags (e.g.
includeArchived) get a second schema; parse booleans withz.enum(["true","false"]).transform(v => v === "true"), neverz.coerce.boolean().
Error Handling
- Throw the classes from
utils/errors.ts— neverres.status().json()for errors. ValidationError400 ·BadRequestError(code, message)400 ·UnauthorizedError(message, code?)401 ·ForbiddenError403 ·NotFoundError404 ·ConflictError(message, code?)409 ·PreconditionFailedError412 ·PayloadTooLargeError413 ·UnprocessableEntityError(code, message)422 ·TooManyRequestsError(retryAfter, message?)429 ·ServiceUnavailableError503 ·OAuth2Error(error, description, status)for OAuth/OIDC.- Archive state:
ArchivedError409resource_archived(changing an archived row),AlreadyArchivedError409already_archived,NotArchivedError409not_archived. A 404 means only that the row is not there or hidden. Seedocs/errors.mdfor the shared codes. - Where nothing can be thrown (a stream's
errorevent after the controller returned), usesendError(res, err)— same body and headers as the handler. - Auth-specific:
InvalidTokenErrorfor bad/expired/revoked bearer tokens. - Prisma constraint errors:
mapUniqueViolation(fn, msg, code)andrethrowRestrictedAsConflict(err, msg)(utils/prismaErrors.ts) turn them into 409s with a specific message. Unmapped unique and foreign-key violations, in any of the shapes the driver adapter produces, reacherrorHandleras a generic 409conflict. errorHandler(last inapp.ts, after a catch-allNotFoundError) answers in RFC 6749 form:{ "error": "code", "error_description": "message" }.
Auth and Permissions
- Tokens are verified in-process by
requireAuth(middleware/auth.ts) — signature, storedaccess_tokensrow, revocation, session, then permissions from the user's roles. No introspection hop and no test-mode shortcut. requireAuthgoes on the area router (routes/v1/admin/index.ts,routes/v1/webhooks/index.ts,routes/v1/audit/index.ts), followed byrateLimitGeneral. Areas with anonymous reads build their own withcreateRequireAuth({ publicRoutes })(routes/v1/files/index.ts); the controller still gates.requirePermission(name)goes on each route. Names come fromPERMISSIONSindefinitions/permissions.ts(files, webhooks, audit —PERMISSIONS.WEBHOOKS.WRITE,PERMISSIONS.buckets.read) or are literal for auth's admin routes ("organizations.write").- Hierarchy (
hasPermission):read (1) < write (2) < archive (3) < delete (4) < admin (5); ax.admincovers its whole subtree, and the bareadminpermission covers everything. - In controllers,
validateUser(req)returns theAuthContext(userId,email,permissions,organizationId,oauthClientId, …). - Finer checks are in code, not middleware:
requireOrgRole("member" | "admin" | "owner", req, orgId)(utils/orgRoleAuth.ts) for org routes;validateBucketAccess/validateBucketManagement(utils/bucketAccess.ts) for the per-bucket grants files layer on top of permissions.
Organization Context
- The tenant comes from the token (
req.auth.organizationId), not a header. - Webhooks:
resolveWebhookOrganizationIdsetsreq.organizationId(awebhooks.admincaller may override withX-Organization-ID); audit:resolveAuditOrganizationId. Controllers read it withvalidateOrganizationId(req)(middleware/organizationId.ts). - Org-owned rows: load by id, then compare
row.organizationIdto the caller's and throwForbiddenError("Resource does not belong to this client"). Lists putorganizationIdin the basewherepassed toapplyCursorToWhere.
Database Layer
- Prisma calls live in
utils/db/{table}.ts, aggregated byutils/db/index.tsinto one default export:import db from "../../utils/db/index.js"→db.organizations.get(id). - Controllers call
db.*, notprisma(a few read-only admin aggregations are the exception). - Every write function takes an optional transaction:
(…, tx?: Prisma.TransactionClient)withconst db = tx ?? prisma;. Controllers opendb.$transaction(async (tx) => …). getthrowsNotFoundError;findById/getBy…returnnullfor the caller to decide.getPaginated({ take, orderBy, where })fetchestake + 1and returns{ data, hasMore }.- Raw SQL names the schema on every table —
"files"."objects","core"."outbox". There is nopublicschema.
Prisma Conventions
- One
prisma/schema.prisma; every model and enum carries@@schema("auth" | "files" | "webhooks" | "audit" | "core"). Import the client and types fromprisma/generated/client/index.js. id String @id @default(uuid(7)) @db.Uuid; foreign keys@db.Uuid(Bucketalone keeps an autoincrementIntid).- camelCase fields,
@map("snake_case")columns,@@map("table_name")tables. createdAt @default(now()),updatedAt @updatedAt, both mapped.- Actors: every "who" column (
createdBy,userId,writerUserId, …) is a FK intoUserwithonDelete: Restrict. Clients act as their service-account user; core acts asSYSTEM_USER_ID. - Archive, don't delete: archivable rows have
archivedAt DateTime? @map("archived_at")+@@index([archivedAt]); mutations on archived rows are blocked withguardArchived/guardArchivedById. Users and organizations are deactivated (isActive) or archived, never deleted.deletedAtexists only onBucket,ObjectandObjectVersion, where deletion is a storage lifecycle (cleanup jobs, delete markers) — filterdeletedAt: nullthere. - Tenant-owned models have
organizationIdwith aRestrictrelation toOrganization.
Migrations
npx prisma migrate dev --create-only --name <name>, review, thennpx prisma migrate deploy.- Append raw SQL only for what Prisma can't express: range partitioning, partial/opclass indexes, CHECK constraints.
- On the partitioned tables (
audit_log_entries,webhook_deliveries) Prisma emits a PK-reset statement Postgres rejects — delete only that statement. src/scripts/seed.tsis idempotent and runs after every migrate.
Declared, Not Seeded
Core's permissions, system roles, buckets and event types are code in src/definitions/
(CORE_DECLARATION), registered by registerCore() on every start. Removing one there retires it
after REGISTRY_RETIREMENT_GRACE_HOURS. Admins never create permissions or edit system roles. App
services declare theirs in their provisioning documents (see docs/provisioning.md, docs/registry.md).
Audit Entries + Webhook Events (Transactional)
Both happen inside the mutation's transaction, from utils/outbox.ts:
const updated = await db.$transaction(async (tx) => {
const next = await db.organizations.update(id, body, tx);
await logAuditTx(tx, {
auth,
organizationId: next.id,
action: "UPDATE",
entityType: "Organization",
entityId: next.id,
entityName: next.name,
changes: computeChanges(existing, next),
});
await emitTx(tx, {
organizationId: getDefaultOrganizationId(),
eventType: "auth.organization.updated",
entityType: "Organization",
entityId: next.id,
data: formatOrganization(next) as Record<string, unknown>,
});
return next;
});
logAuditTxinserts intoaudit.audit_log_entriesdirectly (same database) —userIdis the caller,writerUserIdisSYSTEM_USER_ID, request id and IP come from AsyncLocalStorage.emitTxwrites acore.outboxrow; the worker's reaper fans it out to subscribed endpoints after commit, using the row id as the idempotency key. Don't pass one.emitTxthrows on an event type not indefinitions/eventTypes.ts— add it there first. Format{area}.{resource}.{action}:auth.organization.created,file-service.bucket.updated,core.service.declared.- Platform events (auth, core) are emitted under
getDefaultOrganizationId(); tenant data (buckets, objects) under the owning organization. action:CREATE | UPDATE | DELETE | MERGE | ARCHIVE | RESTORE.
For the reaper, dead-letters and the app-service side (core.outbox in @tereo-io/sdk), invoke
the outbox skill.
App Wiring
app.tsorder:trust proxy→createRequestIdMiddleware()→helmet()→ bucket CORS preflights →apiCors(credentialed CORS on the API only) →authRequestLog→routes(routes/index.ts) → catch-allNotFoundError→errorHandler.routes/index.ts:/.well-known,/health(DB + Redis probe, 503degradedon failure),/oauth,/v1(routes/v1/index.ts:/files,/webhooks,/audit,/account,/admin,/setup, each with its body limit —smallBodyParsersfrommiddleware/bodyParsers.tsfor the auth ones), then the admin UI.index.tsstart order:loadKeys()(before anything signs, verifies or decrypts) → Redis → default organization →assertSystemUser()→registerCore()→ worker (ifROLEruns it) →appimported after Redis is up.- Background work goes in
src/jobs/: queues fromcreateQueue(withRETRY_POLICIES/REMOVAL_POLICIES) inutils/bullmq.ts, BullMQWorkers in the job modules, started fromworker.ts.
Environment Variables
- One schema:
src/env.ts; default exportenv. Add the variable there with a Zod type and default. - Never read
process.envelsewhere.src/preload.tsmust be the first import of every entry point. - Booleans with the
bool("false")helper; numbers withz.coerce.number(). - Keys (
JWT_*,TOTP_ENCRYPTION_KEY,*_SIGNING_KEY_V1) are optional —loadKeys()fills them fromcore.secrets, generating them on first start.
OpenAPI Spec
One spec for the whole service under packages/tereo/openapi/ (openapi.yaml,
paths/<area>/, components/ with components/<area>/, generated bundled-openapi.yaml). Update
it with every route change — invoke the openapi skill.
Adding a Resource
src/routes/v1/{area}/…/{resource}/withindex.ts,schemas.ts,controllers.ts, plus{item}/for the/:idroutes (see theroutesskill)- Model in
prisma/schema.prisma(right@@schema), migration via--create-only src/utils/db/{table}.ts, added toutils/db/index.ts- Mount the router in its parent
index.ts(respect registration order) - Permissions in
definitions/permissions.ts(PERMISSION_CATALOG+PERMISSIONS), and indefinitions/roles.tsif a system role should hold them - Event types in
definitions/eventTypes.ts;logAuditTx+emitTxin every mutation - OpenAPI paths and schemas; tests mirroring the route under
src/tests/routes/(see thetestskill)
DO
- Validate every input with
validateBody/Params/Query; throw error classes for every failure - Keep Prisma in
utils/db/; passtxthrough for writes inside a transaction logAuditTx+emitTxinside the mutation's transactionrequirePermission()on every route; scope tenant data by the caller's organization- Archive (
archivedAt) instead of deleting; keep actor columns as FKs intoauth.users .json every relative import; imports sorted longest-first (npm run sort-imports)- Log with Pino, object first:
logger.info({ orgId }, "msg")
DON'T
- Never
res.status(400).json(...)— throw - Never emit or audit outside the transaction, or fire-and-forget after commit
- Never seed permissions, roles or event types — declare them in
definitions/ - Never put
requireAuthon individual routes — it belongs to the area router - Never read
process.envoutsideenv.ts, or sign/verify beforeloadKeys() - Never write raw SQL without the schema prefix
- Never use
any—unknownand narrow - Never add external API integrations (they belong in dianemo-plugins)
Additional Resources
- Code examples (routers, schemas, controllers, db layer, raw SQL, definitions, Prisma model, env): examples.md
- Related skills:
routes,outbox,openapi,test - Per-area notes:
packages/tereo/docs/{auth,files,webhooks,audit,registry,telemetry}.md - Conventions:
CLAUDE.md,docs/code-style.md