Where enforcement lives
The one thing to understand about this system before changing anything: the database is the security boundary. Everything else is presentation.
Rules: ACC-001 (LAW-grade, non-negotiable), AUD-001, AUD-002 (LAW).
The shell: finding a page, and a tab left open across a release
The sidebar's box narrows the menu — type "voucher" and the menu shows Vouchers, type
"finance" and it shows the whole Finance section. It matches on any word and on the address,
ignores case, and says so when nothing matches. It is not a record search: it finds pages
(src/components/layout/navFilter.ts).
Each build names its screens with its own hash, so a tab opened before a release asks for a
screen that is no longer part of the build; the server answers with the app page and the
browser refuses it as the wrong type. src/lib/staleBuild.ts listens for that failure and
reloads the tab once onto the current build — once, because a reload loop would be worse than
the error. Everything else is left to surface as it is.
1. There is no application server
Browser (React + Vite)
└── src/lib/api.ts ← route handlers that run ON THE CLIENT
└── supabase-js
├── PostgREST → Postgres: RLS, constraints, triggers, SECURITY DEFINER functions
├── GoTrue (Auth)
└── Edge Functions (Deno) — privileged work, secrets, webhooks, public forms,
and every file (the company Shared Drive; no Supabase Storage)
src/lib/api.ts looks like a server. It has route strings, handlers, and
await requirePermission('x.y') at the top of every write. It is not a server. It is
bundled into the page and runs in the user's browser, where the user can change it.
This file runs in the browser, so none of the code below is a security boundary: the RPCs check the caller's permission and confine a partner login to its own bookings themselves. —
src/lib/api.ts
So requirePermission() does a real and useful job — it shapes the screen, gives a clean
403, and documents intent — and it stops nobody. Anything that protects money, data or
access is enforced again in Postgres or in an edge function.
The rule for contributors
A check that exists only in the browser does not count
(ACC-001). When you add a write path, add the
permission check in src/lib/api.ts and make sure the database refuses the same
thing — an RLS policy using auth_user_has_permission('x.y'), a constraint, a trigger,
or a SECURITY DEFINER function that takes the actor from the session.
2. The four mechanisms
Row-level security. Every table has explicit policies per action, tied to a permission or
to ownership. No table allows "any signed-in user" to read or write business data
(ACC-002). Policies call
auth_user_has_permission('x.y'), never a role name. See Row-level security.
Constraints. The cheap, unbypassable checks: a hold must have somebody to hold it for; a seat release's approver cannot be its requester; a refund must be positive; counters cannot go negative.
Triggers. Two jobs. First, guards — Booking_lifecycle_guard, Booking_credit_limit
(a partner's credit limit on every booking write, PTR-030), Payment_guard,
JournalEntry_guard, JournalEntry_limit_guard (approval limits, ACC-030),
TicketRecord_guard, VisaCase_guard and their siblings refuse the
writes a browser session must not make, and silently correct the fields that are derived
rather than entered. Second, derivation — passenger category, booking counts, group
capacity and inventory counters are recomputed from the underlying rows, so a value typed
by a screen is replaced by the real one.
SECURITY DEFINER functions. Anything that needs more than a row write: a business
action that must be all-or-nothing, that must take the actor from the session, or that must
enforce maker-checker. submit_booking, verify_payment, approve_refund,
approve_journal_entries, issue_ticket, change_visa_status, request_seat_release,
open_incident, partner_create_booking — the same shape every time: check the permission,
take the actor from auth.uid(), lock what needs locking, validate, write, audit.
The native app adds nothing outside this pattern. Its tables
(TripGuideStep, GroupActivity, GroupNotice, TravellerLocationShare,
TravellerLocation, TravellerPassportSubmission) are readable under row security and
writable by nobody but their functions: trv_my_trips, trv_trip_programme,
trv_upsert_guide_step / trv_delete_guide_step (programme.manage),
trv_upsert_activity / trv_delete_activity / trv_post_notice (groups.edit or the
group's leader), trv_group_audience, trv_set_location_sharing / trv_report_location
(the traveller's own login, only while trv_booking_is_travelling), fld_group_locations
(the leader or groups.view), trv_submit_passport / trv_review_passport_submission
(bookings.edit to apply). Rules
TRV-001 … TRV-006 and
FLD-006; test
supabase/tests/the_app_for_travellers.sql. The guide's parts, translations and each
traveller's language and tradition (TripGuidePart, TripGuideTranslation,
TravellerGuidePreference) have row security and no policy at all: only functions read or
write them — trv_guide for the traveller, trv_guide_editor / trv_guide_preview for staff,
trv_guide_set_status for approval (guide.religious.approve for religious text) — rules
TRV-018 … TRV-021,
test supabase/tests/guide_languages.sql.
Leave and the employee agreement follow the same pattern (22 · Leave and the employee agreement):
- Row security.
LeaveApplication,LeaveEvent,LeaveLedger,CompOffCredit,LeaveYearClose,EmployeeAgreementandAgreementTemplategrantSELECTonly. An application and its timeline are read by the applicant, by HR and management throughauth_user_has_permission(), by the applicant's reporting manager (leave_may_read()) and by the person it is routed to; the ledger and comp-off credits by their owner and HR only; the year close by HR; an agreement by its employee,hr.agreements.view/hr.agreements.issue, and a countersignatory once the employee has signed.LeaveSettings,LeaveType,HolidayandPeakSeasonare readable by any staff login. The anonymous key reaches none of it (ACC-053). - Functions. Every write is a
SECURITY DEFINERfunction that takes the actor fromauth.uid()throughleave_actor(), refuses a paused login, checks the permission and the routing, and writes anAuditLogrow:leave_apply(all checks inleave_evaluate, the same answerleave_previewshows),leave_decide(leave_can_decide_hr,leave_can_decide_mgmt— never the applicant),leave_cancel,leave_decide_cancellation, the HR functions, andagreement_issue,agreement_sign,agreement_countersign(the password checked againstauth.usersinagreement_check_password, the text's SHA-256 compared). - Triggers.
LeaveLedger_append_only,LeaveEvent_append_only,LeaveYearClose_append_only,LeaveApplication_no_delete;EmployeeAgreement_guard(no delete, no change to the text, hash, values or a given signature, status only forward),EmployeeAgreement_no_truncate,AgreementTemplate_guard. - Constraints. An application's paid and unpaid days add up to its days; one comp-off per person per day; one ledger row per period key, which makes the accruals idempotent; an agreement's signature hashes equal its content hash, and nobody countersigns their own.
- The schedule.
pg_cronjobalhuda-leave-nightlyrunsleave_nightly()at 19:00 UTC (00:30 IST): yearly credits, monthly EL accrual, comp-off expiry (LV-062). The migration skips it, with a notice, on a database withoutpg_cron; HR runs the same catch-up by hand (runbook).
Tests: supabase/tests/leave_follows_the_agreement.sql,
supabase/tests/an_agreement_is_signed_and_kept.sql.
A guard function that needs to know who is calling is deliberately SECURITY INVOKER,
so current_user is the real caller and fin_direct_client() / bl_is_client_write() can
tell a browser session from a definer function.
3. Edge functions
Thirty-three Deno functions in supabase/functions/. They exist for work the browser must
not do: holding a provider secret, verifying a webhook signature, serving a public form,
using the service role.
Notable ones: whatsapp-send (consent and the 24-hour window), whatsapp-webhook,
mailer, communications-dispatcher, visa-intake (public, captcha fails closed,
rate limited), lead-intake, admin-users, permissions-admin, auth-admin-mfa-reset,
session-manager, upload-customer-doc, drive-upload (every other file onto the company
Shared Drive, gated per kind), drive-file (opens any file with a ten-minute link to itself,
checked per kind, TRV-011, ACC-074), extract-passport,
razorpay-webhook and razorpay-order (the app's online payment: the order here, the
receipt only from Razorpay's signed event — API → Online payment),
flight-lookup (the AirLabs flight schedule for the inventory screens, holding the key the
browser used to carry — PLT-015),
finance-copilot, integration-test, secret-selftest.
Each must verify the caller's identity and permission and fail closed (ACC-003); a function called by an outside service verifies a signature instead. A function whose security depends on a secret must refuse the request when the secret is missing, not skip the check (PLT-013).
Two known gaps, recorded rather than hidden
admin-users runs with verify_jwt = false and full service privileges — it must do
its own authentication and fail closed, and
ACC-003 flags it as CRITICAL. whatsapp-webhook fails
open when its signing secret is missing, which
PLT-013 forbids. Check
supabase/config.toml before deploying any function: a function with
verify_jwt = false must call guardRequest or verify a signature.
4. The audit trail
Every change to a business or accounting record is written by the database, with the old value, the new value, who and when — so it cannot be skipped by any screen or API path (AUD-001, Companies (Accounts) Rules r.3(1), LAW). Append-only triggers cover 73 tables, and privileges are revoked so that no user or role, administrator included, can edit, delete or wipe an audit record (AUD-002). If the audit row cannot be written, the business change is rejected.
The reason typed into a confirmation dialog travels with the change and is stored on the audit row (UX-001).
Identity numbers — passport, Aadhaar, PAN, bank account — appear in full only on the record
they belong to. Audit rows, logs, exports and notifications show the last four characters
(AUD-020). Incident audit rows carry no
customer, booking or group tag at all, so health data never surfaces on a timeline that an
ordinary bookings.view holder can read.
Books of account and their audit trail are kept for at least eight financial years, and no automated purge may remove them (AUD-003).
5. Lists are a promise
"This is what exists." Every list endpoint returns a shared paged shape, pages by keyset rather than offset, filters and counts in the database, and throws rather than returning a truncated export. See Lists, paging and search.
6. Intelligence is computed, then explained
Anything that can be derived exactly from records — deadlines, balances, readiness, capacity, drift, duplicates — is computed by rules in the database or shared library code, tested, and cites the rule it implements. A language model is used only for language. A model never produces a number, status or decision that the system then trusts (INT-002).
The system may suggest, rank and warn. It never moves money, changes a status, grants access or sends an external message on its own (INT-003). The one exception is purely internal reminders and escalations defined by a rule.
Every signal shows why — the facts it used and the rule that raised it (INT-004).
7. How a change reaches production
Briefly: a branch, the CI gates, a squash merge to main, then a manually approved pipeline
run that backs up first and applies migrations. Nobody runs supabase db push or dashboard
SQL against production by hand. See Releases and testing and the
deploy runbook.
8. Where to go next
- Stack — the concrete technology choices.
- Permissions system — how a gate is applied at each layer.
- Row-level security — policy shapes and pitfalls.
- Lists, paging and search — the cursor contract.
- Releases and testing — the test stacks and the release path.
- Data model — the core entities.
- Frontend routing — every URL and its gate.