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, missingtenantId, non-stringtenantId, and empty-stringtenantIdas equally invalid. - Returns a frozen-shape object (
{ id, tenantId }) —idis optional in the principal but always a string in the return value (falls back to""). - Throws
CampaignsApiError(TENANT_REQUIRED)withcontext.statusCode === 401so 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 throwsCampaignsApiError(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.