Skip to content
Africa OS Knowledgebase
Esc
navigateopen⌘Jpreview
On this page

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:

  1. Resolve the session from cookies.
  2. Look up the platform profile.
  3. Figure out the active organization.
  4. Compute the permission set.
  5. 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.

Last updated on August 18, 2026