Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
# 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` (uses `PluginDescriptor`/`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:
- `Tenant` interface — single canonical shape. Drop upstream's `TenantConfig` (lifecycle-hooks.ts) and `TenantEventConfig` (event-types.ts). All consumers use `Tenant`.
- `TenantStatus` literal union (`active`/`suspended`/`pending`/`deleted`), `TenantSettings`, `TenantLimits`, `TenantBranding`, `TenantMember`, `TenantRole` (`owner`/`admin`/`member`/`viewer`), `TenantRolePermissions` + `hasPermission(role, perm)`, `TenantInvitation`.
- `TenantResolver` interface (`{ name: string; resolve(c: Context): Promise<Tenant | null> }`) + 6 shipped implementations: `HeaderTenantResolver`, `SubdomainTenantResolver`, `DomainTenantResolver`, `PathTenantResolver`, `JWTTenantResolver`, `ChainedTenantResolver`. All accept a caller-supplied `lookupFn`.
- `TenantCache` — pluggable cache wrapper around any `TenantResolver` with 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 typed `Tenant`. Other accessors (`tenantId`, `tenantContext`) become **typed helpers** that read from `c.get("tenant")` — no duplicate state.
- Hono `ContextVariableMap` augmentation for `tenant: Tenant` so `c.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: bounded `Map` with periodic eviction (LRU cap). Adapters for Redis/SQL can be supplied via `store?: RateLimitStore` interface.
- `TenantLifecycleHooks` (`onTenantCreate/Update/Delete/Suspend/Activate`, `onPluginEnable`, `onPluginDisable`) — registered with the `HookExecutor` from `@suaveplan/api-plugin`. Genesis adds `tenantScopes?: "all" | "explicit"` on `TenantAwarePluginDescriptor` so plugins can opt OUT of tenant events they don't care about.
- `TenantError extends SuaveplanError<TenantErrorCode>` with codes `TENANT_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 is `c.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 `lookupFn` reads 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.** `TenantInvitation` type ships; sending the email is application policy.
- **Tenant feature flags engine.** `isFeatureEnabled` reads `tenant.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 `tenantRateLimit` store 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.

View File

@@ -0,0 +1,171 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-tenant (add-api-tenant)
**Package**: `@suaveplan/api-tenant`
**Status**: PROPOSED
## 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 -->

View File

@@ -0,0 +1,53 @@
# Tasks — add-api-tenant
## 1. Scaffold
- [ ] 1.1 Create `packages/api/api-tenant/` with `src/{types,resolver,cache,context,middleware,rate-limit,lifecycle,storage,error-codes}/` + `tests/setup/`
- [ ] 1.2 `package.json` v0.1.0. Deps: `@suaveplan/error` `@suaveplan/api-plugin` `@suaveplan/events` `@suaveplan/types` `tslib`, `hono` (`catalog:server`), `zod` (`catalog:forms`), `@opentelemetry/api` (`catalog:observability`). Dev: standard testing set. ~9 subpath exports.
- [ ] 1.3 `tsconfig.json` + `tsconfig.lib.json`
- [ ] 1.4 `vite.config.ts` (externals incl. `hono`)
- [ ] 1.5 `vitest.config.ts` (100/100/100/100, forks)
- [ ] 1.6 `tests/setup/polyfills.ts`
## 2. Implementation
- [ ] 2.1 `src/error-codes.ts` + `.md` + test — 7 TENANT.* codes + `TenantError`
- [ ] 2.2 `src/types/tenant.{ts,md,test.ts,index.ts}` — canonical `Tenant` + status/role/permissions + `hasPermission`
- [ ] 2.3 `src/resolver/resolver.{ts,test.ts,md,index.ts}` — 6 resolvers + `ChainedTenantResolver`
- [ ] 2.4 `src/cache/cache.{ts,test.ts,md,index.ts}` — `TenantCache.wrap(resolver, options)` with TTL + negative cache + request coalescing
- [ ] 2.5 `src/context/context.{ts,test.ts,md,index.ts}` — `createTenantContext(tenant)`; **`NullTenantContext` fail-closed**
- [ ] 2.6 `src/middleware/middleware.{ts,test.ts,md,index.ts}` — `createTenantMiddleware`, one canonical `c.set("tenant")`, Hono `ContextVariableMap` augmentation
- [ ] 2.7 `src/middleware/require-plugin.{ts,test.ts,md}` — fail-closed when no tenant
- [ ] 2.8 `src/middleware/require-feature.{ts,test.ts,md}` — fail-closed when no tenant
- [ ] 2.9 `src/rate-limit/rate-limit.{ts,test.ts,md,index.ts}` — `tenantRateLimit` + pluggable `RateLimitStore` + bounded `MemoryRateLimitStore`
- [ ] 2.10 `src/lifecycle/lifecycle.{ts,test.ts,md,index.ts}` — `TenantAwarePluginDescriptor` + `tenantScopes` opt-out + hook dispatch via `@suaveplan/api-plugin` `HookExecutor`
- [ ] 2.11 `src/storage/storage.{ts,test.ts,md,index.ts}` — opt-in `withTenantStorage` ALS support
- [ ] 2.12 `src/index.ts` — named re-exports
## 3. Tests
- [ ] 3.1 Co-located `*.test.ts` per spec scenario
- [ ] 3.2 `src/api-tenant.spec-conformance.test.ts` — every TENANT.* thrown; `expectTypeOf` on `c.get("tenant")`; module-augmentation pinned
- [ ] 3.3 `tests/integration/tenant-middleware-roundtrip.test.ts` — real Hono app + real ChainedTenantResolver + real TenantCache + real MemoryRateLimitStore
- [ ] 3.4 `tests/property.test.ts` — fast-check cache TTL invariant (entry expires exactly at ttlMs boundary)
- [ ] 3.5 `tests/dep-graph.test.ts` — deps + subpaths + sibling .md
- [ ] 3.6 All tests via `@suaveplan/testing/runner`, `{ timeout: 5000 }` (30000 for integration)
## 4. Documentation
- [ ] 4.1 Sibling `.md` per impl (≥200 words)
- [ ] 4.2 `README.md` — resolver examples, cache config, rate-limit store pluggability, ALS opt-in
## 5. Gates
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/api-tenant --force`
- [ ] 5.2 `bunx turbo test:coverage --filter=@suaveplan/api-tenant --force` 100/100/100/100
- [ ] 5.3 `bun scripts/openspec-conformance.ts @suaveplan/api-tenant`
- [ ] 5.4 `bunx turbo build --filter=@suaveplan/api-tenant --force`
- [ ] 5.5 Banned-pattern grep; no `export *`; no direct vitest/bun:test imports
## 6. Archive
- [ ] 6.1 Fold spec → `openspec/specs/api-tenant/spec.md`
- [ ] 6.2 mv change → `openspec/archive/<date>-add-api-tenant/`
- [ ] 6.3 MANIFEST + STATUS refresh