# Add @suaveplan/api-tenant — multi-tenant resolution + scoping (2 of 5) **Package**: `@suaveplan/api-tenant` (NEW, server-tier) **Status**: PROPOSED **Type**: new-package (split from upstream `@suaveplan/api`) - **Spec delta**: ./specs/api-tenant/spec.md - **Companions**: `add-api-plugin`, `add-api-openapi`, `add-api-migrations`, `enterprise-api` - **Position in chain**: depends on `add-api-plugin` (uses `PluginDescriptor`/`PluginContext`/`HookExecutor`) ## Why Upstream packs multi-tenant resolution (`tenant/`, 762 LOC) inside `@suaveplan/api`. Splitting it lets non-tenant apps (internal admin tools, single-org services) compose `@suaveplan/api` without paying the tenant surface cost — and lets tenant-aware consumers depend on a focused package with its own version cycle. The upstream tenant layer has several issues genesis fixes: three overlapping handle shapes set into Hono context (`tenantId`, `tenant`, `tenantContext`) that can drift; AsyncLocalStorage + Hono `c.get` as parallel access paths; `NullTenantContext.isPluginEnabled: () => true` (fail-open footgun); no lookup cache (chained resolvers fire one cold lookup per resolver per request); in-process `tenantRateLimit` Map with unbounded memory; `AppBuilder.tenant(resolver)` plumbed but silently dropped before reaching middleware. ## What changes Net-new package at `0.1.0`. ADDED: - `Tenant` interface — single canonical shape. Drop upstream's `TenantConfig` (lifecycle-hooks.ts) and `TenantEventConfig` (event-types.ts). All consumers use `Tenant`. - `TenantStatus` literal union (`active`/`suspended`/`pending`/`deleted`), `TenantSettings`, `TenantLimits`, `TenantBranding`, `TenantMember`, `TenantRole` (`owner`/`admin`/`member`/`viewer`), `TenantRolePermissions` + `hasPermission(role, perm)`, `TenantInvitation`. - `TenantResolver` interface (`{ name: string; resolve(c: Context): Promise }`) + 6 shipped implementations: `HeaderTenantResolver`, `SubdomainTenantResolver`, `DomainTenantResolver`, `PathTenantResolver`, `JWTTenantResolver`, `ChainedTenantResolver`. All accept a caller-supplied `lookupFn`. - `TenantCache` — pluggable cache wrapper around any `TenantResolver` with TTL + negative-entry cache to prevent stampede on unauthenticated requests. Default: in-memory bounded LRU; pluggable for Redis/SQL. - `createTenantMiddleware({ resolver, required, excludePaths, allowSuspended, onError, onResolved, cache })` — Hono middleware factory. Sets **one** Hono context key: `c.get("tenant")` returning a typed `Tenant`. Other accessors (`tenantId`, `tenantContext`) become **typed helpers** that read from `c.get("tenant")` — no duplicate state. - Hono `ContextVariableMap` augmentation for `tenant: Tenant` so `c.get("tenant")` is fully typed without casts (eliminates upstream's three-key smear). - `createTenantContext(tenant)` — pure factory returning the helper bag (`isPluginEnabled`, `getSetting(dot.path, default?)`, `isFeatureEnabled`, `getLimit`). **Genesis fix:** `NullTenantContext.isPluginEnabled: () => false` (fail-closed). - `requirePlugin(name)`, `requireFeature(name)` middlewares — fail-closed when no tenant resolved. - `tenantRateLimit({ maxRequests, windowMs, store? })` — **pluggable backing store**. Default: bounded `Map` with periodic eviction (LRU cap). Adapters for Redis/SQL can be supplied via `store?: RateLimitStore` interface. - `TenantLifecycleHooks` (`onTenantCreate/Update/Delete/Suspend/Activate`, `onPluginEnable`, `onPluginDisable`) — registered with the `HookExecutor` from `@suaveplan/api-plugin`. Genesis adds `tenantScopes?: "all" | "explicit"` on `TenantAwarePluginDescriptor` so plugins can opt OUT of tenant events they don't care about. - `TenantError extends SuaveplanError` with codes `TENANT_NOT_FOUND`, `TENANT_SUSPENDED`, `TENANT_PENDING`, `TENANT_RESOLUTION_FAILED`, `TENANT_RATE_LIMITED`, `TENANT_INVALID_INPUT`, `TENANT_LOOKUP_TIMEOUT`. - AsyncLocalStorage support is **opt-in via `withTenantStorage(true)`**, not default. Default access is `c.get("tenant")`. Eliminates dual-access-path footgun. - Subpath exports per feature module. ## Out of scope - **Tenant data store / CRUD endpoints.** Genesis ships only the resolution + middleware layer; the caller-supplied `lookupFn` reads from whatever store the app uses (database / config file / in-memory). A reference admin CRUD plugin (`@suaveplan/api-plugin-tenant`) is a separate change. - **Tenant invitation email flow.** `TenantInvitation` type ships; sending the email is application policy. - **Tenant feature flags engine.** `isFeatureEnabled` reads `tenant.settings.features[name]` boolean. A real feature-flag system (`@suaveplan/feature-flags`) is a separate concern; the tenant package only exposes the slot. - **Distributed rate-limit consensus.** The shipped `tenantRateLimit` store interface accepts async impls; building a real Redis token-bucket or SQL-backed counter is a follow-up store package. ## Source mapping | Capability | Upstream source | Notes | |---|---|---| | Tenant types + role/permission | `../suaveplan/packages/server/api/src/tenant/tenant.interface.ts` L1-L147 | verbatim port; drop sibling `TenantConfig`/`TenantEventConfig` | | 6 resolvers + chained | `../suaveplan/packages/server/api/src/tenant/tenant-resolver.ts` L1-L239 | verbatim ports; add `TenantCache` wrapper (genesis-original) | | Tenant context + helpers | `../suaveplan/packages/server/api/src/tenant/tenant-context.ts` L1-L128 | port + flip `NullTenantContext.isPluginEnabled` to false; ALS becomes opt-in | | Tenant middleware + requirePlugin/requireFeature | `../suaveplan/packages/server/api/src/tenant/tenant-middleware.ts` L1-L240 | port shape; collapse 3 `c.set` calls into 1; add `cache` option | | Tenant lifecycle hooks (the 7) | `../suaveplan/packages/server/api/src/hooks/lifecycle-hooks.ts` L62-L126 | port; add `tenantScopes` opt-out on descriptor | | Pluggable rate-limit store | `../suaveplan/packages/server/api/src/tenant/tenant-middleware.ts` L180-L240 | port impl as default `MemoryRateLimitStore`; extract `RateLimitStore` interface; add bounded eviction | ## Impact ### Affected dependents None today — depends on `add-api-plugin` (sibling). Will be consumed by `@suaveplan/api` (orchestrator) and any plugin that wires `requirePlugin(name)` / `requireFeature(name)` middleware.