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

9.6 KiB
Raw Permalink Blame History

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 with host: "foo.app.com:3000"
  • THEN lookupFn is 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 ttlMs against a cached resolver
  • THEN the underlying lookupFn is 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 lookupFn is 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 typed Tenant; c.get("tenantId") and c.get("tenantContext") are NOT set

Scenario: suspended tenant raises TENANT_SUSPENDED

  • WHEN a resolver returns a suspended tenant and allowSuspended is 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 + 1 distinct keys
  • THEN the oldest key is evicted (LRU)

Scenario: limit exceeded responds 429

  • WHEN the same tenant exceeds maxRequests within windowMs
  • THEN the response is 429 with Retry-After header 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 onTenantCreate is NOT called

Scenario: tenantScopes: "explicit" filters by enabledPlugins

  • WHEN a plugin with tenantScopes: "explicit" registers onTenantCreate and a tenant whose enabledPlugins does not include the plugin is created
  • THEN the plugin's onTenantCreate is 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 returns undefined

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"