Files
suaveplan/packages/api-stack/api/api-tenant/src/middleware/middleware.md
2026-05-27 23:29:53 +01:00

3.9 KiB

middleware

(no submodules)

Purpose

The middleware module ships createTenantMiddleware(options) — the Hono middleware that resolves the inbound tenant, applies the status gate, and writes the result under the single canonical c.set("tenant", tenant) key. suaveplan deliberately collapses upstream's three keys (tenantId, tenant, tenantContext) into one; the helper accessors live in context.ts and read from the same key. The module also augments Hono's ContextVariableMap so c.get("tenant") returns a typed Tenant with no cast.

Status gating raises typed TenantError values: deleted → TENANT_NOT_FOUND, suspended → TENANT_SUSPENDED (unless allowSuspended), pending → TENANT_PENDING. A throw from the underlying resolver is wrapped as TENANT_RESOLUTION_FAILED with the original error preserved on .cause. An optional onError(error, c) hook lets callers map errors to bespoke responses without losing the typed contract.

Features

  • Single canonical context key — c.set("tenant", tenant) — typed via the ContextVariableMap augmentation.
  • Status gate covers all four statuses; allowSuspended opt-in lets read-only paths serve suspended tenants.
  • Excluded paths (default /health, /ready, /docs, /openapi) bypass the resolver entirely so liveness probes never block on database health.
  • onResolved(tenant, c) callback fires after the gate passes — use it to attach OTel attributes or per-tenant logger context.
  • onError(error, c) maps TenantError to a Response; non-tenant errors re-throw so the framework's global handler runs.
  • getTenant(c) and getTenantId(c) typed helpers — read the resolved tenant without as Tenant casts.

Usage: Basic

import {
    chainResolvers,
    createTenantMiddleware,
    headerResolver,
} from "@suaveplan/api-tenant";
import { Hono } from "hono";

const app = new Hono();
app.use(
    "*",
    createTenantMiddleware({
        resolver: headerResolver("x-tenant-id", (id) =>
            db.tenants.get(id)
        ),
    })
);
app.get("/me", (c) => c.json({ tenant: c.get("tenant") }));

Usage: Advanced

Composing a cached chained resolver with an error mapper:

import {
    chainResolvers,
    createTenantMiddleware,
    headerResolver,
    subdomainResolver,
    TenantCache,
    TenantError,
} from "@suaveplan/api-tenant";

const resolver = TenantCache.wrap(
    chainResolvers(
        headerResolver("x-tenant-id", lookupById),
        subdomainResolver("suaveplan.io", lookupBySlug)
    ),
    { ttlMs: 30_000 }
);

app.use(
    "*",
    createTenantMiddleware({
        resolver,
        allowSuspended: false,
        excludePaths: ["/health", "/metrics"],
        onError: (error: TenantError, c) =>
            c.json({ code: error.code, message: error.message }, 404),
        onResolved: (tenant, c) => {
            c.header("x-tenant-id", tenant.id);
        },
    })
);

API Reference

  • createTenantMiddleware(options): MiddlewareHandler.
  • interface TenantMiddlewareOptions — resolver, required?, excludePaths?, allowSuspended?, onError?, onResolved?.
  • getTenant(c): Tenant | undefined.
  • getTenantId(c): string | undefined.
  • Module augmentation — declare module "hono" { interface ContextVariableMap { tenant: Tenant } }.

Implementation Notes

Status checks raise BEFORE c.set so a suspended tenant never lands under c.get("tenant") — downstream handlers cannot accidentally serve suspended data. Resolver throws are caught and re-wrapped as TENANT_RESOLUTION_FAILED instead of bubbling up as a raw Error because the framework's global error handler benefits from the discriminated code field. The tenant: Tenant ambient declaration is reachable from anywhere that imports any value from @suaveplan/api-tenant, which is enforced by the side-effect import in the package root barrel.