Request context
How Africa OS turns one HTTP request into a fully provisioned Effect runtime - user, organization, permissions, and a run function for server programs.
The request context is the heart of the platform. Instead of each server function hand-assembling its own session, organization, database, and logger, Africa OS builds one request runtime that bundles everything together. Server functions call runRequest and receive a fully provisioned context.
The problem it solves
In a naive design, every server function would:
- Resolve the session from cookies.
- Look up the platform profile.
- Figure out the active organization.
- Compute the permission set.
- Construct a database handle.
That is error-prone, repeated everywhere, and a place where tenant bugs hide. Africa OS centralizes it in @africaos/request-context.
The request pipeline
buildRuntime(headers, options) assembles the runtime for a single request:
Each step is an Effect program run against a ManagedRuntime sharing a process-wide memo map, so the Postgres pool and the Better Auth instance are built once per process, not once per request.
What the runtime carries
export interface RequestRuntime {
readonly user: CurrentUserValue; // who is calling
readonly organization: Option.Option<Organization>; // active tenant
readonly application: Option.Option<CurrentApplicationValue>; // scoped application
readonly permissions: ReadonlySet<Permission>; // capabilities
readonly run: (program) => Promise<Success>; // execute an Effect program
}
The run function executes an Effect program inside the request’s runtime, with every request-scoped service already provided. Programs can require CurrentUser, CurrentOrganization, CurrentApplication, CurrentPermissions, RequestContext, and all the platform services.
Resolution rules
Active organization. The URL slug (:organization) wins when it resolves to a real organization that is one of the user’s active memberships. Otherwise the africaos.active_org cookie wins when it names an active membership. Otherwise the first active membership. No memberships means None.
Application. Only relevant for organization-scoped requests (:organization/:application/*). The application must exist in the registry (ApplicationNotFound), be enabled for the active organization (ApplicationDisabled), and the user must hold platform.applications.access (Forbidden). This is the platform-level gate before any domain permission matters.
Permissions. Resolved once per request, scoped to the active organization, via AuthorizationService.permissionsFor(userId, organizationId). A user with no active organization holds an empty set.
The URL scope
Server functions that live inside an application receive the URL scope so the runtime verifies the segments server-side:
export interface RequestScope {
readonly organizationSlug?: string;
readonly applicationSlug?: string;
}
The runtime never trusts the URL on its own: an unknown slug fails with OrganizationNotFound, a real organization the user does not belong to fails with NotOrganizationMember, and a disabled application fails with ApplicationDisabled.
Using it in server functions
import { runRequest } from "@africaos/request-context";
import { CurrentOrganization } from "@africaos/request-context";
const listProducts = createServerFn({ method: "GET" })
.validator((data: { organizationSlug: string }) => data)
.handler(async ({ data }) => {
const organization = await runRequest(request.headers, Effect.gen(function* () {
const org = yield* CurrentOrganization;
return org;
}), { scope: { organizationSlug: data.organizationSlug } });
// ...
});
The scope option tells the runtime to verify the slug server-side rather than falling back to the cookie default.
runRequestFolded - handling stale sessions
runRequest authenticates inside buildRuntime, so a missing or expired session rejects with Unauthenticated before the program can fold its own failures. runRequestFolded catches exactly that one rejection and maps it to a caller-chosen neutral result:
const products = await runRequestFolded(
headers,
program,
{ scope },
() => [] // unauthenticated → empty list, never a 500
);
Every other failure still rejects. This keeps direct calls and stale sessions from surfacing as server errors.
Request-scoped services
Programs may require any of:
| Tag | Holds |
|---|---|
CurrentUser |
The resolved platform user. |
CurrentOrganization |
The active organization (Option). |
CurrentApplication |
The scoped application (Option). |
CurrentPermissions |
The capability set (Set<Permission>). |
RequestContext |
All of the above in one value. |
| Platform services | Database, Logger, AuthService, OrganizationService, MembershipService, AuthorizationService, RegistryService. |
Why this matters for tenancy
Because CurrentOrganization comes from the runtime, application code cannot accidentally use a client-supplied organization id. The tenant boundary is enforced by construction: the runtime resolved it, and the database layer provides orgWhere/orgScope to keep queries scoped to it.
Next steps
- Permissions - what the permission set contains and how guards use it.
- Database - how queries are kept tenant-scoped.