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

172 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->