Skip to main content

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​

MethodPathAuthNotes
GET/healthnoneLiveness
GET/POST/api/auth/*—Better Auth: sign-in, sign-up, session, organization, invitations
GET/modulestenantModule codes this tenant is entitled to
GET/modules/:codetenantThe module_definitions row (entities/fields/relationships/permissions). Cached 10 minutes — platform-seeded, and no write route exists
GET/reference-datatenantAll 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}.

MethodPathPermission
GETlisted abovenone beyond tenant
POSTlisted aboveorgStructure:create

Creates go through createTenantRecord, which writes the audit row in the same transaction.

People​

MethodPathPermissionNotes
GET/peopletenantSearchable/filterable list, indexed ORDER BY firstName
GET/people/:idtenantFull detail, including addresses, sensitive identifiers, enrollments, employment
GET/people/:id/attendancetenantAttendance history for that person
POST/peoplepeople:createGraph-synced (upsertPersonVertex)
PATCH/people/:idpeople:updateGraph-synced
POST/people/:id/addressespeople:update1:many, no graph sync
PATCH/people/:id/addresses/:addressIdpeople:update
POST/people/:id/sensitive-identifierspeople:updateSeparate table for field-level privacy
PATCH/people/:id/sensitive-identifierspeople:update
POST/people/:id/enrollmentpeople:updateGraph-synced (upsertEnrollmentEdge)
PATCH/people/:id/enrollment/:enrollmentIdpeople:updateGraph-synced
POST/people/:id/employmentpeople:update
PATCH/people/:id/employmentpeople:update

Attendance​

MethodPathPermissionNotes
GET/attendance/assignmentstenantFaculty subject assignments with joined names
POST/attendance/assignmentsfacultyAssignments:manageCreates the authorization fact, not just a schedule entry
GET/attendance/recordstenantRoster plus existing marks for a class section / subject / date
POST/attendance/recordsattendance:mark + per-route checkSee below
GET/attendance/delegationstenantDelegation requests with both faculty parties joined
POST/attendance/delegationsattendance:markRequest that another faculty member take a class
PATCH/attendance/delegations/:idattendance:markAccept / 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​

MethodPathPermissionNotes
GET/custom-fieldscustomFields:manageDefinitions for this tenant
POST/custom-fieldscustomFields:manageAdds a field with no migration and no deploy
PATCH/custom-fields/:fieldId/deactivatecustomFields:manageSoft-deactivate
GET/audit-logauditLog:view50 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:

Statementadminfacultystudentstaff
orgStructurecreate, update, delete, viewview——
peoplecreate, update, delete, viewviewviewview
attendancemark, viewmark, viewview—
customFieldsmanage———
facultyAssignmentsmanage, viewview——
auditLogview———

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 Zod flatten() for validation failures (400), 401 unauthenticated, 403 forbidden, 404 not 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 existing withTenant() 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 filter deletedAt 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 hostname 0.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): max from DATABASE_POOL_MAX, idle_timeout: 60 (postgres.js defaults to null, which would let each instance hold max connections open forever at zero load), connect_timeout: 10.