first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user