Data Access: Server Functions, Adapters, Projections
The browser can never call apps/api directly, because the WorkOS session lives in an httpOnly cookie the client canβt read, and the JWT must never reach client code. So every read and write goes through a two-file pattern in apps/portal/src/server/ β a createServerFn transport that route code imports, and a .server.ts implementation that only ever executes on the Worker. The display layer, meanwhile, is locked to rendering serialized projections by a CI gate. This page explains all three.
The adapter pair
Each domain gets a pair β crm-client(.server).ts, kpi-client(.server).ts, fund-accounting-client(.server).ts, portco-, collection-, import-, extraction-review-, portal-search-, and so on.
The transport half (kpi-client.ts) is a thin createServerFn whose handler dynamically imports the server half, and injects itself into a typed contract client so wire shapes come from the shared contract package (no drift):
// apps/portal/src/server/kpi-client.ts
const requestKpiApi = createServerFn({ method: "POST" })
.validator((input: KpiRequestInput) => input)
.handler(async ({ data }) => {
const { requestKpiApiServer } = await import("./kpi-client.server")
return (await requestKpiApiServer(data)) as KpiJson
})
export const kpiClient: KpiClient = makeKpiClient({
async request(input) { return requestKpiApi({ data: input }) },
})The server half (kpi-client.server.ts) does the sensitive work:
resolveForwardableSessionToken(getRequest(), β¦)from@opsdna/portal-sessionunseals the cookie and refreshes the access token if needed β any refreshedset-cookieheaders are appended to the response, so the session rolls forward transparently.- A
RequiresAuthfailure becomes an auth-session-recovery redirect (ADR-0024 layer 2 β mid-session expiry lands back at login withreturn_to). - The fetch to
${API_BASE_URL}${path}carriesauthorization: Bearer <jwt>plusactiveTenantHeader()(see Auth & Tenancy) and, in previews,previewApiAuthHeader(). - Workspace-loss statuses trigger
maybeThrowNoWorkspaceRecoveryRedirectinstead of a raw error.
The dynamic import inside the handler matters: it keeps getRequest, cookie crypto, and secrets out of the browser bundle β the same reason portal-auth-gate-server.ts is only ever reached via its server function.
Whatβs on the other end
apps/api serves read-models and projections, not raw tables. For LP-facing surfaces, ADR-0015 sets the posture: tenant RLS stays hard, and the LP portal reads only recipient-authorized shared rows or packets (data_share_grant, shared_resource_snapshot) β never provider tables via cross-tenant joins. Projection types the display layer consumes come from @opsdna/portal-projections / @opsdna/portal-read-models (see /api and /entity-model).
The display-only rule
Route components (apps/portal/src/routes/**) and shared view modules (apps/portal/src/portal/**) render from serialized projections and static fixtures β nothing else. scripts/check-portal-display-only-imports.ts (run as portal:display-only-check inside bun run verify and CI) fails the build if display code imports, at runtime:
@opsdna/persistenceor@opsdna/ledger(type-only imports are allowed β types erase at compile time),@opsdna/canonical-json(serialization belongs to projection loaders),- any
migrationspath, or server-only modules.
Violating display-only is not a style nit. A runtime import of persistence/ledger from a route module smuggles server-side substrates (and potentially migration SQL) into the browser bundle. The gate exists because this failure mode is silent until bundle-audit or a prod incident catches it. If a route needs data, the answer is always: add it to a projection and fetch it through a server-fn adapter β never reach past the seam.
Where things live
- companies.index.tsx (display-only; loader β server fn)
- kpi-client.ts (createServerFn transport)
- kpi-client.server.ts (JWT + headers + fetch)
- active-tenant-header.ts
- auth-session-recovery.ts
Related
- Auth & Tenancy β the gates the JWT and tenant header answer to.
- Adding a Page β the how-to that stitches route + server fn + adapter together.
- /api β the endpoints these adapters call.