9.6 KiB
Capability Spec: api-tenant
Package: @suaveplan/api-tenant
Status: ACTIVE
ADDED Requirements
Requirement: Canonical Tenant type + role + permissions
The package SHALL export a single Tenant interface ({ id, name, slug, status, plan?, settings, enabledPlugins, domains, metadata?, createdAt, updatedAt }) with TenantStatus = "active" | "suspended" | "pending" | "deleted", TenantRole = "owner" | "admin" | "member" | "viewer", TenantRolePermissions permission map, and hasPermission(role, perm) helper. Wildcard "*" on owner grants every permission.
Scenario: owner has every permission
- WHEN
hasPermission("owner", "any:string")is called - THEN the result is
true
Scenario: viewer cannot update tenant
- WHEN
hasPermission("viewer", "tenant:update")is called - THEN the result is
false
Verified by: src/types/tenant.test.ts::"owner has wildcard permission" AND src/types/tenant.test.ts::"viewer cannot tenant:update"
Requirement: Six built-in resolver strategies + chained composition
The package SHALL ship HeaderTenantResolver, SubdomainTenantResolver (strips www + port), DomainTenantResolver, PathTenantResolver, JWTTenantResolver (reads c.get("user")[claim]), and ChainedTenantResolver (first non-null wins). All resolvers accept a caller-supplied lookupFn for the actual data lookup.
Scenario: subdomain resolver strips port and ignores www
- WHEN a
SubdomainTenantResolver("app.com", lookupFn)resolves a request withhost: "foo.app.com:3000" - THEN
lookupFnis called with"foo"
Scenario: chained resolver returns first non-null
- WHEN
ChainedTenantResolver(headerResolver, jwtResolver, subdomainResolver)resolves a request where only the JWT resolver returns a tenant - THEN the chain returns the JWT tenant and does not call the subdomain resolver
Verified by: src/resolver/resolver.test.ts::"subdomain strips port and www" AND src/resolver/resolver.test.ts::"chained returns first non-null" AND src/resolver/resolver.test.ts::"jwt reads claim from c.get('user')"
Requirement: TenantCache prevents stampede with TTL + negative cache
TenantCache.wrap(resolver, options) SHALL return a new TenantResolver that caches positive lookups for ttlMs and negative lookups for negativeTtlMs (default smaller). Cache key is the underlying resolver's discriminating input (host/subdomain/path/header). Concurrent in-flight lookups for the same key SHALL share a single Promise (request coalescing).
Scenario: positive cache hit avoids second lookup
- WHEN the same tenant is resolved twice within
ttlMsagainst a cached resolver - THEN the underlying
lookupFnis called exactly once
Scenario: negative cache prevents lookup stampede
- WHEN the same unmatched key is resolved 100× in parallel against a cached resolver
- THEN the underlying
lookupFnis called exactly once
Verified by: src/cache/cache.test.ts::"positive hit skips second lookup" AND src/cache/cache.test.ts::"negative hit prevents stampede" AND src/cache/cache.test.ts::"in-flight lookups coalesce"
Requirement: Tenant middleware sets one canonical Hono context key
createTenantMiddleware(options) SHALL set c.set("tenant", tenant) exactly once per request. It SHALL NOT set duplicate keys (tenantId, tenantContext). The package SHALL augment Hono's ContextVariableMap so c.get("tenant") returns Tenant with no cast. Status checks (deleted → TENANT_NOT_FOUND, suspended (unless allowSuspended) → TENANT_SUSPENDED, pending → TENANT_PENDING) SHALL raise typed errors.
Scenario: middleware sets exactly one context key
- WHEN a request resolves and middleware completes
- THEN
c.get("tenant")returns the typedTenant;c.get("tenantId")andc.get("tenantContext")are NOT set
Scenario: suspended tenant raises TENANT_SUSPENDED
- WHEN a resolver returns a suspended tenant and
allowSuspendedis false - THEN the middleware throws
TenantError(TENANT_SUSPENDED)
Verified by: src/middleware/middleware.test.ts::"sets one c.get('tenant') key" AND src/middleware/middleware.test.ts::"suspended raises TENANT_SUSPENDED" AND src/middleware/middleware.test.ts::"deleted raises TENANT_NOT_FOUND"
Requirement: NullTenantContext is fail-closed
NullTenantContext.isPluginEnabled(name) SHALL return false for every name. isFeatureEnabled SHALL return false. getLimit SHALL return undefined. getSetting SHALL return the supplied default. This is a deliberate behaviour change from upstream (which returns true) to prevent silent permission grants on tenant-less requests.
Scenario: NullTenantContext denies every plugin
- WHEN
NullTenantContext.isPluginEnabled("payment")is called - THEN the result is
false
Verified by: src/context/context.test.ts::"NullTenantContext fail-closes plugin check" AND src/context/context.test.ts::"NullTenantContext fail-closes feature check"
Requirement: requirePlugin / requireFeature middleware fail closed
requirePlugin(name) and requireFeature(name) SHALL raise TenantError(TENANT_NOT_FOUND) when no tenant is resolved (paired with NullTenantContext fail-closed semantics).
Scenario: requirePlugin on tenant-less request raises
- WHEN
requirePlugin("payment")runs on a request with no resolved tenant - THEN the middleware throws
TenantError(TENANT_NOT_FOUND)
Verified by: src/middleware/require-plugin.test.ts::"raises when no tenant resolved" AND src/middleware/require-feature.test.ts::"raises when no tenant resolved"
Requirement: Pluggable tenantRateLimit store with bounded eviction
tenantRateLimit({ maxRequests, windowMs, store? }) SHALL accept a pluggable RateLimitStore interface (get(key), set(key, value), delete(key)). The default MemoryRateLimitStore SHALL be bounded by maxEntries (default 10_000) with LRU eviction on overflow. Exceeding the request limit SHALL set Retry-After and respond 429 with TenantError(TENANT_RATE_LIMITED).
Scenario: memory store evicts oldest beyond cap
- WHEN the in-memory store receives
maxEntries + 1distinct keys - THEN the oldest key is evicted (LRU)
Scenario: limit exceeded responds 429
- WHEN the same tenant exceeds
maxRequestswithinwindowMs - THEN the response is 429 with
Retry-Afterheader set
Verified by: src/rate-limit/rate-limit.test.ts::"memory store LRU evicts beyond maxEntries" AND src/rate-limit/rate-limit.test.ts::"exceeds limit responds 429 with Retry-After" AND src/rate-limit/rate-limit.test.ts::"pluggable store interface"
Requirement: TenantAwarePluginDescriptor supports tenantScopes opt-out
The package SHALL export TenantAwarePluginDescriptor extending PluginDescriptor from @suaveplan/api-plugin with tenantScopes?: "all" | "explicit" | "none" (default "all"). When "none", the registered tenant lifecycle hooks SHALL not fire for that plugin. When "explicit", only tenants whose enabledPlugins includes the plugin name receive the hook calls.
Scenario: tenantScopes: "none" skips the plugin
- WHEN a plugin declares
tenantScopes: "none"and a tenant is created - THEN the plugin's
onTenantCreateis NOT called
Scenario: tenantScopes: "explicit" filters by enabledPlugins
- WHEN a plugin with
tenantScopes: "explicit"registersonTenantCreateand a tenant whoseenabledPluginsdoes not include the plugin is created - THEN the plugin's
onTenantCreateis NOT called
Verified by: src/lifecycle/lifecycle.test.ts::"tenantScopes none skips hook" AND src/lifecycle/lifecycle.test.ts::"tenantScopes explicit filters by enabledPlugins"
Requirement: AsyncLocalStorage is opt-in
The package SHALL ship withTenantStorage(enabled: boolean) builder option. When enabled=true, the middleware wraps next() in tenantStorage.run(tenant, next) so getCurrentTenant() is available deep in the call stack. When enabled=false (default), no ALS scope is created and getCurrentTenant() always returns undefined. This eliminates the upstream dual-access-path footgun where c.get("tenant") and getCurrentTenant() could disagree.
Scenario: ALS off → getCurrentTenant is undefined
- WHEN
withTenantStorage(false)is set and a request resolves a tenant - THEN
getCurrentTenant()called from any depth returnsundefined
Verified by: src/storage/storage.test.ts::"ALS off keeps getCurrentTenant undefined" AND src/storage/storage.test.ts::"ALS on threads tenant through async stack"
Requirement: Every TENANT_* code is thrown from a production path
TENANT_CODES SHALL define exactly 7 codes (TENANT_NOT_FOUND, TENANT_SUSPENDED, TENANT_PENDING, TENANT_RESOLUTION_FAILED, TENANT_RATE_LIMITED, TENANT_INVALID_INPUT, TENANT_LOOKUP_TIMEOUT). Every code SHALL be raised by at least one production call site.
Verified by: src/api-tenant.spec-conformance.test.ts::"every TENANT_* code is thrown from a production path"
Requirement: Server-tier package shape
The package SHALL run in Bun + Node (server-tier — uses Hono types and node:async_hooks). package.json SHALL declare subpath exports per feature module. Every implementation .ts SHALL have a sibling .md ≥200 words. No export * in feature barrels. 100/100/100/100 coverage.
Verified by: tests/dep-graph.test.ts::"dependencies match the server allowlist" AND tests/dep-graph.test.ts::"subpath exports cover every feature" AND tests/dep-graph.test.ts::"every impl .ts has a sibling .md"