API surface
apps/api is a Hono app on Bun, and it is one application even though it fronts several concerns:
the module engine's routes, Better Auth's handler, RBAC middleware, and tenant resolution. It exports
its route type (export type AppType = typeof app) so apps/web can consume it through Hono's
typed RPC client — that is where end-to-end type safety comes from, with no hand-written per-endpoint
fetch wrappers.
Middleware chain
request
↓
CORS (origin = WEB_APP_URL, credentials: true)
↓
onError → Sentry.captureException + {"error":"internal_error"} 500
↓
/health no auth
/api/auth/* → Better Auth handler (GET + POST)
/modules/*, /org-structure/*, /people/*, /attendance/*,
/reference-data/*, /custom-fields/*, /audit-log/*
↓
tenantMiddleware session → activeOrganizationId → tenants.betterAuthOrgId → tenantId
↓ sets c.tenantId / c.userId / c.orgId, wraps handler in Sentry tenant scope
requirePermission({ ... }) only on write routes + /audit-log + /custom-fields
↓
handler must call withTenant(tenantId, ...) before touching tenant tables
tenantMiddleware returns 401 for no session, 400 if the session has no active organization,
and 404 if no tenants row is linked to that organization.
Endpoints
Platform
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /health | none | Liveness |
| GET/POST | /api/auth/* | — | Better Auth: sign-in, sign-up, session, organization, invitations |
| GET | /modules | tenant | Module codes this tenant is entitled to |
| GET | /modules/:code | tenant | The module_definitions row (entities/fields/relationships/permissions). Cached 10 minutes — platform-seeded, and no write route exists |
| GET | /reference-data | tenant | All six global lookup tables in one response. Cached |
Org structure
Seven entities, each with a list and a create route: /org-structure/{departments, programmes, branches, batches, staff-categories, subjects, class-sections}.
| Method | Path | Permission |
|---|---|---|
| GET | listed above | none beyond tenant |
| POST | listed above | orgStructure:create |
Creates go through createTenantRecord, which writes the audit row in the same transaction.
People
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /people | tenant | Searchable/filterable list, indexed ORDER BY firstName |
| GET | /people/:id | tenant | Full detail, including addresses, sensitive identifiers, enrollments, employment |
| GET | /people/:id/attendance | tenant | Attendance history for that person |
| POST | /people | people:create | Graph-synced (upsertPersonVertex) |
| PATCH | /people/:id | people:update | Graph-synced |
| POST | /people/:id/addresses | people:update | 1:many, no graph sync |
| PATCH | /people/:id/addresses/:addressId | people:update | |
| POST | /people/:id/sensitive-identifiers | people:update | Separate table for field-level privacy |
| PATCH | /people/:id/sensitive-identifiers | people:update | |
| POST | /people/:id/enrollment | people:update | Graph-synced (upsertEnrollmentEdge) |
| PATCH | /people/:id/enrollment/:enrollmentId | people:update | Graph-synced |
| POST | /people/:id/employment | people:update | |
| PATCH | /people/:id/employment | people:update |
Attendance
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /attendance/assignments | tenant | Faculty subject assignments with joined names |
| POST | /attendance/assignments | facultyAssignments:manage | Creates the authorization fact, not just a schedule entry |
| GET | /attendance/records | tenant | Roster plus existing marks for a class section / subject / date |
| POST | /attendance/records | attendance:mark + per-route check | See below |
| GET | /attendance/delegations | tenant | Delegation requests with both faculty parties joined |
| POST | /attendance/delegations | attendance:mark | Request that another faculty member take a class |
| PATCH | /attendance/delegations/:id | attendance:mark | Accept / decline; admins may act for either party |
POST /attendance/records is the one route where role membership is necessary but not
sufficient. After the permission check, if the caller's role is not admin, the route verifies
there is an active facultySubjectAssignments row matching the acting person, the class section,
and the specific subject. A faculty member assigned to a different subject in the same class section
is rejected — mirroring a real timetable period. The submission is also designed to avoid N+1: one
batched lookup of existing rows for that (classSectionId, date, periodRef) feeds an in-memory map,
rather than one SELECT per roster row.
Custom fields and audit
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /custom-fields | customFields:manage | Definitions for this tenant |
| POST | /custom-fields | customFields:manage | Adds a field with no migration and no deploy |
| PATCH | /custom-fields/:fieldId/deactivate | customFields:manage | Soft-deactivate |
| GET | /audit-log | auditLog:view | 50 per page, ORDER BY occurredAt DESC, optional ?entityType= filter |
RBAC model
Declared once in apps/api/src/auth.ts as Better Auth access-control statements, printed here as
the declared intent:
| Statement | admin | faculty | student | staff |
|---|---|---|---|---|
orgStructure | create, update, delete, view | view | — | — |
people | create, update, delete, view | view | view | view |
attendance | mark, view | mark, view | view | — |
customFields | manage | — | — | — |
facultyAssignments | manage, view | view | — | — |
auditLog | view | — | — | — |
creatorRole for a new organization is admin.
Current authorization reality
Worth stating plainly, because the matrix above can be read as more complete than it is:
requirePermission is applied to write routes, /audit-log, and /custom-fields — but not to
the read routes for people, org structure, or attendance. Those are protected by tenantMiddleware
alone, which establishes which organization and who, not what they may see. In practice that
means any authenticated member of a tenant's organization — including a student or staff role —
can list all people, all org structure, and all attendance records for that tenant via the API.
Two things are true and worth keeping distinct: tenant isolation is genuinely enforced and tested (no member of institution A can read institution B's rows), while intra-tenant role scoping on reads is declared in the model and not yet applied at the route layer. The source comments state that student/staff reads are intended to be restricted to their own records at the API layer; that restriction is not implemented today. See Roadmap for the fix.
Conventions the routes follow
- Every response is JSON. Errors are
{ "error": "<code>" }or a Zodflatten()for validation failures (400),401unauthenticated,403forbidden,404not found. - Every write goes through
mutation-service.ts, never a direct insert:createTenantRecord,updateTenantRecord,softDeleteTenantRecord. Each one writes the audit row and runs the optional graph-sync callback inside the caller's existingwithTenant()transaction, so a relational row, its audit entry, and its graph vertex/edge all commit or all roll back together. - Deletes are soft (
deletedAt), and read paths filterdeletedAt IS NULL. There is no hard-delete helper. Bun.serve()is left on defaults deliberately — the default-export{ port, fetch }form is Bun's own recommended shape and picks up hostname0.0.0.0, no idle timeout cap, and HTTP/1.1 keep-alive. TLS and multi-instance concerns are deploy-time, not app-code, decisions.- Connection pooling is tuned, not defaulted (
packages/db/src/client.ts):maxfromDATABASE_POOL_MAX,idle_timeout: 60(postgres.js defaults tonull, which would let each instance holdmaxconnections open forever at zero load),connect_timeout: 10.