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(usesPluginDescriptor/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:
Tenantinterface — single canonical shape. Drop upstream'sTenantConfig(lifecycle-hooks.ts) andTenantEventConfig(event-types.ts). All consumers useTenant.TenantStatusliteral union (active/suspended/pending/deleted),TenantSettings,TenantLimits,TenantBranding,TenantMember,TenantRole(owner/admin/member/viewer),TenantRolePermissions+hasPermission(role, perm),TenantInvitation.TenantResolverinterface ({ name: string; resolve(c: Context): Promise<Tenant | null> }) + 6 shipped implementations:HeaderTenantResolver,SubdomainTenantResolver,DomainTenantResolver,PathTenantResolver,JWTTenantResolver,ChainedTenantResolver. All accept a caller-suppliedlookupFn.TenantCache— pluggable cache wrapper around anyTenantResolverwith 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 typedTenant. Other accessors (tenantId,tenantContext) become typed helpers that read fromc.get("tenant")— no duplicate state.- Hono
ContextVariableMapaugmentation fortenant: Tenantsoc.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: boundedMapwith periodic eviction (LRU cap). Adapters for Redis/SQL can be supplied viastore?: RateLimitStoreinterface.TenantLifecycleHooks(onTenantCreate/Update/Delete/Suspend/Activate,onPluginEnable,onPluginDisable) — registered with theHookExecutorfrom@suaveplan/api-plugin. Genesis addstenantScopes?: "all" | "explicit"onTenantAwarePluginDescriptorso plugins can opt OUT of tenant events they don't care about.TenantError extends SuaveplanError<TenantErrorCode>with codesTENANT_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 isc.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
lookupFnreads 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.
TenantInvitationtype ships; sending the email is application policy. - Tenant feature flags engine.
isFeatureEnabledreadstenant.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
tenantRateLimitstore 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.