172 lines
9.6 KiB
Markdown
172 lines
9.6 KiB
Markdown
<!-- 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 -->
|