Files
2026-05-27 23:29:53 +01:00

6.2 KiB

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<Tenant | null> }) + 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<TenantErrorCode> 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.