Architecture
Data Flow & Auth
Request lifecycle#
Every authenticated web request passes through four layers:
Browser
→ Clerk middleware (apps/web/middleware.ts)
→ Next.js App Router (edge or server)
→ Hono.js API (apps/api — Cloudflare Worker)
→ Neon PostgreSQL (Drizzle ORM)Clerk middleware#
apps/web/middleware.ts uses Clerk's Next.js middleware to protect all routes under /app/**. Unauthenticated requests are redirected to the marketing sign-in page. The middleware runs at the edge and adds no database round-trip.
Hono API auth pattern#
The Hono API receives requests with a Clerk session token in the Authorization: Bearer header. A global middleware on the Hono app verifies the JWT using jose (edge-compatible) and extracts the following claims:
// AppEnv type in apps/api/src/types.ts
interface AuthContext {
userId: string; // Clerk user ID
orgId: string; // Clerk org ID — scopes all DB queries
role: string; // org:admin | org:manager | org:officer
permissions: string[]; // dot-notation permission strings
}
// Every route handler accesses auth via Hono context:
app.get('/farmers', async (c) => {
const { orgId, permissions } = c.get('auth');
// all queries are scoped to orgId
});Multi-tenancy model#
Each commodity firm or cooperative maps to exactly one Clerk Organization. The organization's ID (org_...) is the tenant key. Every Neon table that stores tenant data has an org_id column. Every query is scoped to the authenticated user's orgId claim.
| Clerk concept | Platform concept |
|---|---|
| Clerk Organization | Tenant (one agribusiness entity / cooperative) |
| Clerk org member | User within that tenant |
| Clerk org role (admin/manager/officer) | Coarse role assignment |
| Clerk publicMetadata (on OrgMembership) | Fine-grained permission set |
| Clerk org ID (org_...) | Primary tenant key — scopes all Neon queries |
Neon reference tables#
-- Tenant settings (Clerk org is the identity source)
CREATE TABLE orgs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clerk_org_id TEXT UNIQUE NOT NULL, -- e.g. org_2abc123
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
plan VARCHAR(50) DEFAULT 'free',
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Domain data always references org_id, not clerk_org_id directly
CREATE TABLE farmers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES orgs(id),
-- ...
);RBAC — permission layer#
Fine-grained permissions are defined in shared/auth-permissions/src/index.ts as a PERMISSIONS const object. Three roles exist, each with a fixed permission set:
| Role ID | Permission set |
|---|---|
org:admin | All permissions |
org:manager | All except compliance.evidence_package, audit.report, surveys.respond, and some remediation permissions |
org:officer | View + create for most domains; respond to surveys; upload remediation evidence; no management of other users |
Permission domains#
| Domain | Permissions |
|---|---|
| Settings | settings.view, settings.manage |
| Regions | regions.view, regions.manage |
| Districts/Communities | districts.manage, communities.manage |
| Farmers | farmers.view, farmers.create, farmers.manage |
| Farms & Plots | farms.view, farms.manage, plots.view, plots.manage |
| Programs | program_types.view/manage, programs.view/create/manage, activities.view/create/manage, attendance.record, inputs.record, evidence.upload, coverage.view |
| Surveys | surveys.view/create/manage/publish/assign/respond/export |
| Deforestation | assessments.view/request, remediation.manage, remediation.evidence_upload, risk.override |
| Compliance | compliance.view, compliance.report_generate, compliance.evidence_package, audit.view, audit.report |
Clerk permission key format#
Clerk dashboard permission keys only allow [a-z0-9_]. Our canonical permission format uses dots (farms.view). Two helper functions in @repo/auth-permissions handle the round-trip:
import { toClerkPermissionKey, toCanonicalPermission } from '@repo/auth-permissions';
// When writing to Clerk publicMetadata:
toClerkPermissionKey('farms.view') // → 'farms_view'
// When reading JWT claims from Clerk:
toCanonicalPermission('farms_view') // → 'farms.view'SWR data fetching pattern#
Frontend feature packages use SWR for all client-side data fetching. Shared configuration lives in @repo/swr-config and is applied globally via the SWR provider in apps/web/app/providers.tsx.
| Pattern | Convention |
|---|---|
| Hook naming | useFarmers, useFarmerDetail, useBulkImport |
| SWR key | URL string matching the API route — e.g. /farmers?orgId=... |
| Error handling | SWR error state; display via ErrorBoundary or per-hook check |
| Mutation | mutate() after POST/PATCH/DELETE to revalidate affected keys |
| Optimistic update | mutate() with optimisticData for immediate UI feedback |