Files
skills/docs/api-stack/api/api-plugin-campaigns/src/routes/shared/require-tenant.md
2026-05-29 08:20:56 +01:00

3.0 KiB

require-tenant

Purpose

requireTenant extracts the request principal from the Hono context slot c.get("user") (populated by @suaveplan/api-plugin-auth before route dispatch) and either returns its non-empty tenantId plus optional id or throws CampaignsApiError carrying the CAMPAIGNS_API.TENANT_REQUIRED code with HTTP 401 wired into the error's context. This is the single tenant-presence gate every campaign, suppression, and webhook route relies on, so handlers can treat tenantId as a definitely-present string thereafter.

The helper does not consult a tenant resolver fallback (no ?? "default" short-circuit). Multi-tenant correctness is the campaigns plugin's primary obligation — silently falling back to a shared tenant would let one tenant's misconfiguration leak data into another's namespace. Failing closed with a 401 surfaces the problem loudly.

Features

  • Pure synchronous read; no I/O, no DB, no allocator churn.
  • Treats null, undefined, missing tenantId, non-string tenantId, and empty-string tenantId as equally invalid.
  • Returns a frozen-shape object ({ id, tenantId }) — id is optional in the principal but always a string in the return value (falls back to "").
  • Throws CampaignsApiError(TENANT_REQUIRED) with context.statusCode === 401 so the package error handler can map uniformly.

Usage: Basic

import { requireTenant } from "@suaveplan/api-plugin-campaigns";

app.openapi(listRoute, async (c) => {
    const { tenantId } = requireTenant(c);
    const result = await c.get("campaignRepository").list({ tenantId });
    return c.json({ campaigns: result, total: result.length }, 200);
});

Usage: Advanced

import {
    CAMPAIGNS_API_CODES,
    CampaignsApiError,
    requireTenant,
} from "@suaveplan/api-plugin-campaigns";

async function authorize(c: Context, target: { tenantId: string }) {
    const { tenantId } = requireTenant(c);
    if (target.tenantId !== tenantId) {
        throw new CampaignsApiError(
            CAMPAIGNS_API_CODES.CAMPAIGN_NOT_FOUND,
            "campaign belongs to a different tenant",
            { context: { statusCode: 404 } }
        );
    }
}

API Reference

  • interface TenantPrincipal { readonly id: string; readonly tenantId: string }
  • requireTenant(c: Context): TenantPrincipal — returns the principal or throws CampaignsApiError(TENANT_REQUIRED).

Implementation Notes

The principal type is cast through unknown rather than imported from @suaveplan/api-plugin-auth because the campaigns plugin must type-check in isolation (consumers may swap in a different auth provider). The structural check (typeof principal.tenantId === "string" && principal.tenantId.length > 0) is intentionally explicit: TypeScript's narrowing for record-typed unknown does not collapse the two checks into one. The empty-string check is the load-bearing one — Zod's min(1) on the auth side prevents most empty values, but defending against an out-of-band setter (e.g. a test fixture passing { tenantId: "" }) keeps the contract honest.