Tenancy

Tenancy answers "which account, workspace, organization, or tenant is this work scoped to?" Keep it separate from authentication. A request can be authenticated and still lack a valid tenant for the resource it is trying to read or mutate.

The recommended shape is:

  1. Resolve a tenant scope from verified request state.
  2. Store it on ctx.tenant as an ActivityTenant.
  3. Scope repository reads and writes by tenant ID.
  4. Re-check loaded resources in policies so direct lookups and background work cannot cross tenant boundaries.

Resolve tenant scope

Put tenant resolution in an app-owned helper instead of scattering x-tenant-id reads across routes or use cases. Verified sources should come first: session membership, API key principals, JWT or OIDC claims, host/path mapping, or request metadata that has already been checked against app state.

import { createTenant } from "@beignet/core/ports";
import type { AuthSession } from "@/ports/auth";

export function resolveRequestTenant({
  auth,
  headers,
}: {
  auth: AuthSession | null;
  headers: Headers;
}) {
  const tenantId =
    tenantIdFromSession(auth) ??
    tenantIdFromDemoHeader(headers) ??
    "tenant_example";

  return createTenant(tenantId);
}

function tenantIdFromSession(auth: AuthSession | null) {
  const session = auth?.session;

  return isRecord(session) ? nonEmptyString(session.tenantId) : undefined;
}

function tenantIdFromDemoHeader(headers: Headers) {
  return nonEmptyString(headers.get("x-tenant-id"));
}

function nonEmptyString(value: unknown) {
  return typeof value === "string" && value.trim().length > 0
    ? value.trim()
    : undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

Treat headers such as x-tenant-id as local-demo or development fallbacks unless another trusted layer has verified that the caller belongs to that tenant. Production apps should usually resolve tenant scope from verified session membership, API key principal data, organization claims, subdomains, or path segments checked against membership records.

Request context

The server context is the right default place to resolve tenant scope once per request:

import { createAnonymousActor, createUserActor } from "@beignet/core/ports";
import { defineServerContext } from "@beignet/core/server";
import { resolveRequestTenant } from "@/lib/tenant";
import type { AppContext } from "@/app-context";

export const appContext = defineServerContext<
  AppContext,
  AppContext["ports"]
>()({
  gate: (ports) => ports.gate,
  request: async ({ req, ports, requestId, trace }) => {
    const auth = await ports.auth.getSession(req);
    const tenant = resolveRequestTenant({ auth, headers: req.headers });

    return {
      actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(),
      auth,
      requestId,
      ...trace,
      ports,
      tenant,
    };
  },
});

The exact resolver is app-owned because tenant models vary. Some apps are single-tenant, some use workspace memberships, and API-only apps may resolve tenant scope from an API key or OAuth client.

API keys and service actors

For API-key routes, resolve the tenant from the verified principal returned by the credential verifier. Do not trust a caller-supplied tenant header on those routes:

import { createServiceActor, createTenant } from "@beignet/core/ports";
import { createAuthHooks } from "@beignet/core/server";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const apiKeyHeadersSchema = z.object({
  authorization: z.string().startsWith("Bearer "),
});

export const apiKeyAuth = createAuthHooks<AppContext>()({
  headers: apiKeyHeadersSchema,
  resolve: async ({ ctx, headers }) => {
    const apiKey = headers.authorization.replace(/^Bearer\s+/i, "");
    const principal = await ctx.ports.apiKeys.verify({
      apiKey,
    });

    if (!principal) return null;

    return {
      actor: createServiceActor(principal.id, {
        metadata: { scopes: [...principal.scopes] },
      }),
      tenant: createTenant(principal.tenantId),
    };
  },
});

This keeps the tenant, actor, and scopes tied to one verified credential. Route metadata and OpenAPI security can then describe the transport, while policies still enforce business access.

Use cases and policies

Use cases that touch tenant-owned data should require a tenant before loading records:

import { requireTenantId } from "@beignet/core/ports";

const record = await ctx.ports.records.findById({
  recordId: input.recordId,
  tenantId: requireTenantId(ctx),
});

Repositories should filter by tenant ID so unrelated records are not loaded. Policies should still verify loaded subjects belong to the current tenant:

function sameTenant(ctx: AuthorizationContext, record: RecordSummary) {
  if (ctx.tenant?.id === record.tenantId) return allow();

  return deny({
    reason: "Record belongs to another tenant.",
    code: "TENANT_MISMATCH",
  });
}

Filtering prevents accidental disclosure. Policy checks make the rule explicit, testable, visible in devtools, and reusable from jobs, tasks, schedules, and scripts.