KPI Media · Internal · Technical design

How it is built

The how. CONTEXT.md is the language and docs/adr/ is the why — where this and an ADR disagree, the ADR wins and this is wrong. Read the glossary first: an Employee is not a User, a Settlement is not a payment prediction, a Statutory Rate is not a Settlement Rate. The code uses those names exactly.

4 schemas 2 request paths 3 environments 6 CI gates 4 nevers

01Topology

%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 46, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
  BROWSER["Browser"]

  subgraph CF["Cloudflare"]
    PAGES["Pages
hrms.kpimedia.co
the SPA, a static build
CDN and TLS"] R2["R2
bytes only, reached server-side
nightly dumps · Storage backend"] end subgraph ZB["Zeabur · Singapore"] KONG["Kong
PostgREST · GoTrue · Storage"] NODE["Node service
api/ and jobs/"] PG[("Postgres
RLS on every table")] end BROWSER -->|"loads the app"| PAGES BROWSER -->|"simple reads and writes
hrms-data"| KONG BROWSER -->|"invariant writes
hrms-api"| NODE KONG --> PG NODE --> PG NODE -->|"dumps and files"| R2 KONG -.->|"Storage backend"| R2
Figure 1  Reading a hostname tells you which path you are on. hrms-data is the browser reading through PostgREST with RLS enforcing. hrms-api is our own service, for writes that carry invariants.

DNS is at GoDaddy, and there is no WAF. kpimedia.co carries the Webflow marketing site and Google Workspace MX; moving nameservers to gain a proxy this platform does not need would risk both. Cloudflare’s role is Pages and R2 only.

02Environments

Three, and they are never connected — no sync, no replication. Only code and migration files travel between them, through git.

HostDataAccessDeploys
localsupabase startReal names, invented compensationBoth engineers
devThe supabase-dev stack in the shared Zeabur project, on *.pages.dev and *.zeabur.appReal names, invented compensationBoth — the junior by URL and anon key only, no dashboardOn merge to main
productionZeabur, hrms / hrms-data / hrms-api under kpimedia.coRealSenior onlyOn push to release

The hosting domain and the sign-in domain are deliberately different

The app is on kpimedia.co. Google sign-in is restricted to the Workspace domain, which is kpimedia.sg. Both are correct. Do not “fix” one to match the other.

03The two request paths

The most important rule in the codebase.

Path A — browser straight to PostgREST. Reads, lists and simple writes. RLS decides what comes back. PostgREST supplies real server-side search, filtering and pagination, which is why lists are correct here and were not in the prototype.

Path B — browser to the Node API. Writes carrying an invariant a policy cannot express, because they need orchestration and one transaction.

Which path?

Use Path B if the write does any of these:

  • moves something through a state machine (draft → submitted → finalised)
  • checks who the actor is relative to the record — a submitter may not approve their own payroll run
  • snapshots a version so a later reprint reproduces the original figures
  • writes more than one table and all of them must land or none
  • computes money

Everything else is Path A. When unsure, ask — do not guess toward Path B “to be safe”. Moving a read into the API means writing filtering and pagination by hand and losing the RLS enforcement that comes free.

%%{init: {"flowchart": {"padding": 14, "nodeSpacing": 44, "rankSpacing": 46, "useMaxWidth": true}}}%%
flowchart TB
  W["A write arrives"] --> Q1{"Does it move a state machine,
check the actor against the row,
snapshot a version,
write several tables,
or compute money?"} Q1 -->|"no — the common case"| A["Path A
browser straight to PostgREST
RLS decides what lands"] Q1 -->|"yes"| Q2{"Does it compute money,
reach outside Postgres,
or orchestrate several steps?"} Q2 -->|"no"| RPC["Path B — an RPC
plpgsql through /rpc/
security invoker, so RLS applies"] Q2 -->|"yes"| ND["Path B — Node
Hono route, caller's JWT
RLS still applies"] Q2 -.->|"genuinely ambiguous?
choose Node"| ND
Figure 2  Reads are never Path B. Moving one into the API means hand-writing filtering and pagination, and losing the RLS enforcement that came free.

Path B: Node, or a Postgres function?

A plpgsql function called through /rpc/ is a transaction, and with security invoker it runs as the calling user so RLS still applies. Use an RPC when the operation is a guarded multi-table write and nothing more. Use Node when it computes money, calls anything outside Postgres, or orchestrates several steps — money computation in particular stays in TypeScript, because the golden fixtures call computeStatutory() directly and the branded money types do not exist in plpgsql. When genuinely ambiguous, choose Node: easier to test and to review.

%%{init: {"flowchart": {"padding": 14, "nodeSpacing": 50, "rankSpacing": 48, "useMaxWidth": true}}}%%
flowchart TB
  U["A signed-in person"] --> API["src/api/
Hono routes
forwards the caller's JWT"] CRON["The scheduler — nobody signed in"] --> JOBS["src/jobs/
payroll batches · PDFs · email · backups
service-client.ts, the only file
that builds service_role"] API -->|"RLS applies"| PG[("Postgres")] JOBS -->|"bypasses RLS entirely"| PG API -.->|"calls notify as a function —
never imports the client"| JOBS BAN["ESLint boundary rule
any import of service-client.ts
from src/api/ fails the build"] BAN -.- API
Figure 3  The boundary is a build rule, not a deployment boundary. service_role really does sit in the environment of the process that serves user requests — the lint rule is what keeps it out of reach, which is why suppressing it is a review failure rather than a style disagreement.
The credential rule

Path B connects as the calling user, forwarding their JWT, so RLS still applies inside our own API. service_role bypasses RLS entirely; it is constructed in exactly one file, src/jobs/service-client.ts, and an ESLint boundary rule fails the build if anything under src/api/ imports it. If you find yourself wanting that key in a request path, the policy is wrong — fix the policy.

04Repository layout

Stack: React + Vite for the SPA, static, on Pages. Hono on Node for src/api/, in the same container as src/jobs/. node-cron for the scheduler. zod for request validation at the API boundary, which is where the branded money types are enforced.

app/
├── supabase/
│   ├── migrations/          numbered SQL, applied in order, everywhere
│   ├── seed.sql             real people, invented compensation
│   └── config.toml          pinned image versions
├── src/
│   ├── shell/               registry, nav, layout, auth guard
│   ├── core/                Employee, Account, Role, Permission, audit
│   ├── modules/
│   │   ├── hrms/            payroll, claims, compensation
│   │   │   ├── ui/          React — built into the SPA
│   │   │   └── domain/      pure TS. computeStatutory() and friends.
│   │   │                    No I/O, no database. Imported by api/ and
│   │   │                    jobs/, called directly by the fixtures
│   │   ├── orgchart/
│   │   └── leave/
│   ├── api/                 Node/Hono, Path B. Caller's JWT. Never service_role
│   └── jobs/                Node, scheduled. service_role lives here
└── tests/
    ├── fixtures/            golden payroll examples
    └── rls/                 role-by-role authorization tests
A module owns a Postgres schema and a code folder. It may read core. It may not touch another module’s tables or import another module’s code — ESLint enforces both, and core is the only shared dependency.

05Database

Schemas. core (Employee, Account, Role, Permission, audit), hrms, org, leave. Plus ops, which is not a module schema and holds no domain data. Migrations are numbered files applied in order in every environment.

Never change schema by clicking in Studio

Local and production diverge silently, and nobody finds out until a migration fails in production. supabase db reset rebuilds local from migrations plus seed in about thirty seconds, so there is no reason to.

RLS is on every table with no exceptions. As of August 2026 this is not yet true of the running codebase — the policies are written in 0004_rls.sql and have never been executed. Closing that is the first foundation ticket, and no new table should be added before it is.

Policies may be AI-drafted; they are proven by the matrix, not by review. An RLS bug is an omission, so reading a diff is not the control. The matrix is derived from CONTEXT.md and the ADRs before any policy is written, signed by the senior, and lives in tests/rls/:

RoleRelationship to rowTableOpExpect
Employeeowncore.employee_compensationselectvia view
Employeeowncore.employee_compensationupdatedeny
Employeeownleave.requestinsertallow
Finance / HRanycore.employee_compensationselectallow
Supervisordirect reportleave.requestupdateallow
Supervisordirect reporthrms.claimselectdeny
Super Adminis the submitterhrms.payroll_runapprovedeny
anonymousvia share linkorg.employee_org_profileselect4 cols

A person reads their own compensation through core.my_compensation, a definer view whose where clause is the whole protection — the employee role holds no grant on the base table at all. Reads are a PDPA right of access; writes are denied, because self-editing bank_account_no is the payroll-diversion path. Whoever owns the result is the senior, whatever drafted it, and a PR that changes a policy and its test together fails review.

Audit logging is database triggers, not application code, so neither request path can bypass it.

Known defect carried from the prototype

core.employee_compensation holds monthly_salary_local with a currency, implying the local figure is contractual. It is not — salary is contracted in SGD and the local amount is an outcome of the transfer. The column becomes monthly_salary_sgd, and currency describes only which rail the payment leaves on. Fix this before payroll code reads the column.

06Authentication

Google Workspace SSO only, no passwords, no self-registration. The flow matters, because getting it backwards breaks every preview deploy:

%%{init: {"sequence": {"useMaxWidth": true, "wrap": true, "width": 168}}}%%
sequenceDiagram
  autonumber
  participant S as SPA
  participant G as GoTrue
  participant GO as Google
  participant DB as Postgres

  S->>G: /authorize
  G->>GO: redirect, restricted to the Workspace domain
  Note over GO: the Internal consent screen is
the real domain restriction GO->>G: back to GoTrue's callback — fixed per environment G->>DB: insert into auth.users DB->>DB: trigger — find an active Employee
holding that work_email alt an Employee matches DB->>DB: create the Account, grant 'employee' else nobody matches DB->>DB: create nothing — no access at all end G->>S: session
Figure 4  Google only ever sees GoTrue’s callback URL, which is fixed per environment. The SPA’s own origin changes on every preview deploy and is allowed by wildcard in Supabase’s redirect allowlist. Register the SPA origin with Google instead and sign-in breaks on every PR preview.

Authorization is by Permission, in a module namespace — payroll.approve, orgchart.edit. Roles are only bundles of Permissions and carry no authority of their own. The catalogue is fixed in migrations; which roles hold which permissions is data a super admin may change.

Supervisor authority is derived, never granted. It is read from the reporting line at the moment of the check. It covers leave approval and does not extend to Claims — those are decided by Finance and then a Super Admin, and a Supervisor is never shown their team’s spending.

07Secrets

Secretlocaldevproduction
anon keyCLI-printedPages + Zeabur envPages + Zeabur env
service_roleCLI-printedZeabur env, jobs onlyZeabur env, senior’s password manager, nowhere else
DB passwordZeaburSenior’s password manager
R2 tokensZeaburZeabur
Resend keyZeaburZeabur

GitHub Actions holds dev secrets only. No production secret ever enters GitHub, Slack, or a chat message. Postgres is not exposed publicly on either stack.

08TypeScript

strict, everywhere, including the ported prototype — converted during the move rather than as a second sweep.

Money is never a bare number. An SGD figure and a local figure are different types and must not be assignable to one another. That mistake is the entire reason for the distinction between Compensation (always SGD) and Settlement (local, known only afterwards).

type SGD   = number & { readonly __brand: 'SGD' }
type Local = number & { readonly __brand: 'Local'; readonly currency: Currency }
Rates. StatutoryRate and SettlementRate are distinct types with no conversion between them. A Statutory Rate computes; a Settlement Rate records. They must never be reconciled.

Dates. A payroll month is not a date — model it as its own type. Same for a Leave Year, which closes on 19 December rather than 31 December. Database types are generated from the schema, never hand-written, and regenerated whenever a migration lands.

09Files

Claim receipts and employee documents go to Supabase Storage on a Cloudflare R2 backend. Bucket RLS policies do authorization — the same model as the tables, so a receipt that shows a medical condition is protected by a policy rather than by a code path someone remembered to write.

Set STORAGE_BACKEND=s3 on the Storage container and point it at the environment’s R2 bucket; check the exact variable names against the image version you pinned. Buckets: kpi-files-dev, kpi-files-prod, and kpi-backups (jobs service only). Receipts ship in phase one, with a mandatory set per category.

10Email

Resend, transactional only. Verify the sending domain with SPF, DKIM and DMARC in GoDaddy, where DNS lives — and merge the SPF include into the record Google Workspace already has rather than adding a second v=spf1, which breaks both.

Email notifies; it never carries. A payslip notification says a payslip is ready and links to the app. It does not attach it, and it does not put a figure in the subject line or the body. Rejections of a Claim or a Leave application carry their written reason — that is text the submitter already has the right to see.

11Errors and monitoring

Four alarms, all through one notify() function to one Slack channel. The operator is part-time, so a quiet channel must mean a healthy system.

  1. Application errors — SPA and Node
  2. Uptime — a health endpoint that actually queries Postgres, not one that returns 200 unconditionally
  3. Backup did not land in R2 last night
  4. A scheduled job failed — the 19 December leave close, service award accrual on 1 January, identity destruction five years after last day

Alarms 3 and 4 matter most, because both failures look exactly like nothing happening.

%%{init: {"flowchart": {"padding": 14, "nodeSpacing": 44, "rankSpacing": 44, "useMaxWidth": true}}}%%
flowchart TB
  E["An error, anywhere"] --> FP["fingerprint =
hash of name + message + top stack frame"] FP --> LOG[("ops.error_log
every single one
RLS denies everyone
inside the nightly dump")] FP --> Q{"Has this fingerprint
posted in the last
15 minutes?"} Q -->|"yes"| SUP["suppress
increment the count"] Q -->|"no"| SLACK["Slack
fingerprint · count · module
route · employee_code
and nothing else"] SUP -.->|"the count rides on
the next post"| SLACK
Figure 5  The cooldown is not optional. One throwing loop without it posts ten thousand Slack messages and rate-limits you out of your own alerting.

Slack payloads are built from a fixed field list, never from an error object: fingerprint, count, module, route, employee_code. Nothing else. This is why there is no scrubbing configuration — an SDK that serialises stack frames can leak a salary or a national id out of a local variable, so we do not send stack frames anywhere.

ops.error_log holds no domain data, no module may read it, RLS denies everyone, and only the service role writes. It is inside the Postgres dump, so it is backed up like everything else.

How SPA errors get there: the browser cannot write to ops.error_log — RLS denies it, and it must. So src/api/ exposes one route, POST /internal/client-error, which takes only the fixed field list and hands it to the same notify() path; the browser decides nothing about what is sent. That route is reachable by any signed-in user, so it needs its own rate limit — per account, not per fingerprint — or one looping client fills the table.

12Testing and CI

Every PR must pass all six, before a human reads it.

CheckWhy
tsc --strict
Lint, including the service_role boundary ruleSecurity-critical, never suppressed
Unit tests
supabase db resetProves migrations apply from zero — catches the drift that kills self-hosted setups
Golden payroll fixturesAuthored by the senior and Finance before payroll code exists
RLS testsRole restrictions must be verified by calling the API directly, not by clicking through the UI
%%{init: {"flowchart": {"padding": 12, "nodeSpacing": 30, "rankSpacing": 40, "useMaxWidth": true}}}%%
flowchart TB
  PR["A pull request"] --> G1["tsc --strict"]
  G1 --> G2["lint —
including the service_role
boundary rule
"] G2 --> G3["unit tests"] G3 --> G4["supabase db reset
on a clean container"] G4 --> G5["golden payroll fixtures"] G5 --> G6["RLS tests
signs in as each role"] G6 --> H["a human reads it
one approving review, the senior"] H --> M["squash merge to main"] M --> D["dev updates itself"]
Figure 6  All six run before a human reads anything. db reset proves the migrations apply from zero, which catches the drift that kills self-hosted setups. None of these exist yet — there is no .github/ directory.

The RLS suite signs in as each role and asserts what it can and cannot read, against the matrix in §05. It is the suite that would have caught the Base44 defect this rebuild exists to fix, and it is the one everybody skips.

None of these exist yet

There is no .github/ directory and no workflows, so the status checks main’s branch protection requires have nothing to run. And there is no app/package.json, so the RLS suite cannot execute even locally. Both are the first ticket.

13Workflow

Branches. main is protected — PR, one approving review from the senior, CI green, no force-push; merging deploys dev. release is protected so only the senior may push; pushing deploys production.

A ticket. Branch → build locally → supabase db reset and tests → PR → a preview URL appears pointing at dev → review → squash merge → dev updates itself.

%%{init: {"flowchart": {"padding": 14, "nodeSpacing": 44, "rankSpacing": 50, "useMaxWidth": true}}}%%
flowchart LR
  FB["feature branch
the junior"] -->|"PR · CI green ·
one approving review"| MAIN["main
protected
no force-push"] FB -.->|"every branch builds
a preview on pages.dev"| PREV["preview URL
points at dev"] MAIN ==>|"automatic"| DEV["dev stack
real names
invented compensation"] MAIN -->|"git merge --ff-only
only the senior may push"| REL["release
protected"] REL ==>|"automatic"| PROD["production
real salaries
bank accounts · national ids"] MIG["migrations, applied by hand
after a rehearsal on a restored dump"] -.->|"always before promoting"| PROD
Figure 7  Two protected branches are the entire access boundary. release is the only branch that deploys production, and one person can push it — which is what makes production unreachable for anyone else, without needing a second system to enforce it.

Who does what

Junior — buildsSenior — reviews, operates
TicketsPicks the next, asks when unclearWrites, sequences, scopes
Domain rulesImplements what the ticket saysAnswers them — holds the payroll knowledge
CodeWrites it, with tests and RLS tests for any table addedReviews and approves every PR
MigrationsWrites the file, applies locally and to devApplies to production, after rehearsing
Environmentslocal + devlocal + dev + production, alone
Data importNot involvedWrites and runs it — real salaries and national ids
Backups, restores, upgrades, monitoringNothingAll of it
IncidentsFixes the bug once diagnosedDiagnoses, decides, restores

The junior owns the change. The senior owns the system.

The gap in this table

It says the senior reviews every PR. It does not say how fast. A full-time engineer blocked on review cannot merge, cannot start the next ticket, and will either idle or guess — and this is the single most likely way to waste their first month. Commit to a number, and name where questions go between reviews.

14The four nevers

  1. Never change schema in Studio. Migration files only.
  2. Never import service_role under src/api/. If a policy is in the way, fix the policy.
  3. Never put real compensation on a laptop. The seed carries real names, codes and work emails — kept forever anyway — and entirely invented salaries, bank details and national ids. Production dumps are anonymised or on an encrypted disk.
  4. Never push to main or release. Everything arrives by PR.

Each one maps to a CI check or a permission, not to goodwill.