brick.ts

Execution Context & Services

Resolve request context, inject databases and users, and isolate in-process service calls.

llms.txt

Execution context provides request metadata, database handles, logging, and user authentication state to actions and lifecycle hooks.

Minimal Example

Configure a service context resolver to parse authentication headers and inject user identity into actions:

src/services/billing.ts
import { defineService, t } from "@brickkit/core";

// Define custom session shape
interface BillingUser {
  id: string;
  plan: "free" | "pro";
}

export const billingService = defineService("billing", {
  context: async (baseCtx): Promise<{ user: BillingUser | null }> => {
    const auth = baseCtx.request.headers.get("authorization");
    if (!auth) return { user: null };

    // Example token validation
    const user =
      auth === "Bearer pro-token"
        ? { id: "usr_42", plan: "pro" as const }
        : { id: "usr_10", plan: "free" as const };

    return { user };
  },
});

export const getSubscription = billingService.action({
  name: "getSubscription",
  output: t.Object({ plan: t.String() }),
  execute: ({ ctx, error }) => {
    // Error helpers throw; `return` lets TypeScript narrow `ctx.user`
    if (!ctx.user) return error.UNAUTHORIZED("Sign in required");
    return { plan: ctx.user.plan };
  },
});

BaseContext Properties

Every action invocation and service resolver receives a BaseContext containing HTTP and runtime telemetry primitives:

FieldTypeDescription
requestRequestWeb-standard Request for the active HTTP call, or a placeholder http://localhost request in-process.
dbTDbDatabase instance from the owning service's handle.
loggerLoggerLogger with .info(), .warn(), .error(), and .debug().
traceIdstringTrace ID. Over HTTP, taken from the x-trace-id header when present (e.g., tr_k2l9p0).
requestIdstringRequest ID. Over HTTP, taken from the x-request-id header when present (e.g., req_k2l9p0).
servicesRecord<string, any>Reserved. Brick passes an empty object; use createServiceProxy to call other services.
metadataRecord<string, unknown>Over HTTP, { headers } with the request headers; an empty object in-process.

Over HTTP, Brick echoes traceId and requestId back as x-trace-id and x-request-id response headers.

Service Context Resolvers

Brick passes BaseContext to the service's context resolver on every HTTP request and every in-process call:

src/services/projects.ts
export const projectsService = defineService("projects", {
  context: async (baseCtx) => {
    const tenantId = baseCtx.request.headers.get("x-tenant-id") ?? "default";
    baseCtx.logger.info(`Resolving context for tenant: ${tenantId}`);

    return {
      tenantId,
      resolvedAt: Date.now(),
    };
  },
});

The returned object is merged with BaseContext to form ActionContext. Inside execute({ ctx }), TypeScript infers both BaseContext properties (like ctx.db and ctx.logger) and the resolved properties (like ctx.tenantId).

Context Isolation Across In-Process Service Calls

When calling another service in-process using createServiceProxy, Brick enforces strict context isolation:

src/services/notifications.ts
import { createServiceProxy, defineService } from "@brickkit/core";

export const notificationsService = defineService("notifications");

// The type parameter is the shape you call; Brick doesn't infer it
type UsersApi = {
  getUser: (input: {
    id: string;
  }) => Promise<{ id: string; name: string; email: string }>;
};

// Proxy to the "users" service, looked up by name in the global registry.
// `from` identifies the calling service, so `users` actions see `ctx.caller === "notifications"`.
const users = createServiceProxy<UsersApi>("users", { from: "notifications" });

export const sendWelcome = notificationsService.action({
  name: "sendWelcome",
  execute: async ({ ctx }) => {
    // Proxy methods take (input, callCtx?) and run the action in-process
    const user = await users.getUser({ id: "usr_1" });
    ctx.logger.info(`Sending welcome email to ${user.email}`);
    return { sent: true };
  },
});

Each proxy method takes the action's input as its first argument and an optional call context as its second. Only in-process (monolith) dispatch is implemented; createServiceProxy(name, { isMonolith: false }) throws when called.

Security Boundaries

What the receiving service sees:

  1. Own context and database: The receiving service runs its own context resolver and uses its own database.
  2. Framework fields are dropped: db, request, logger, services, and metadata in the call context are discarded before reaching the receiving service.
  3. Envelope fields pass through: Only caller, user, traceId, requestId, and tenantId are forwarded. The resolver receives these fields and its returned values always take precedence. Caller custom fields such as session and isOwner are discarded for direct action calls and proxy calls. Treat tenantId as a claim to validate, not proof of access.
  4. ctx.caller: The proxy sets ctx.caller to from — the name you passed when creating the proxy, createServiceProxy<UsersApi>("users", { from: "notifications" }) — never the target service's name. When from is provided, any caller in the proxy's options.context or the per-call context is ignored, so it cannot be spoofed. Without from, an explicitly supplied caller still passes through; otherwise caller is left unset.

Internal calls without cookies

A proxy call receives a placeholder request without browser cookies. A resolver that supports internal callers should validate envelope.user and derive its own session and helpers. For example, accept a user ID only after checking it with your identity store and caller trust policy:

const accounts = defineService("accounts", {
  context: async (envelope) => {
    const claim = envelope.user;
    const userId =
      typeof claim === "object" &&
      claim !== null &&
      "id" in claim &&
      typeof claim.id === "string"
        ? claim.id
        : undefined;
    // Application functions validate the caller and load an allowed identity.
    const user =
      userId && isTrustedCaller(envelope.caller)
        ? await loadAllowedUser(userId, envelope.tenantId)
        : null;
    return {
      user,
      session: null,
      isOwner: (ownerId: string) => user?.id === ownerId,
    };
  },
});

For HTTP calls, continue resolving identity from the actual request's authentication headers or cookies. Passing { user } directly cannot replace a resolver's returned user, even when the resolver returns null. Tests should provide a request or configure a resolver that explicitly validates the internal envelope.