first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# `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
```ts
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
```ts
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.