Initial Commit
This commit is contained in:
51
packages/api-stack/api/api-tenant-provider/README.md
Normal file
51
packages/api-stack/api/api-tenant-provider/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @suaveplan/api-tenant-provider - Abstract `TenantProvider` contract for the Suaveplan API plugin system. Enumerates the universe of tenants known to a host so per-tenant cron / queued jobs (manifest `perTenant: true`) and admin tooling can fan out one invocation per tenant. Ships the interface, the `TenantRecord` Zod schema, the `ListOpts` pagination shape, the `CapabilityId` augmentation contributing `"tenant.provider"`, the frozen `TENANT_PROVIDER_CODES` error registry, and a reusable `runTenantProviderConformance(factory)` harness — no concrete source (in-memory, DB, directory-service adapters land in sibling packages).
|
||||
|
||||
Abstract `TenantProvider` contract for the Suaveplan API plugin
|
||||
system. Enumerates the universe of tenants known to a host so
|
||||
per-tenant cron / queued jobs (manifest `perTenant: true`) and admin
|
||||
tooling can fan out one invocation per tenant.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [capability-id](./src/capability-id/capability-id.md) — `@suaveplan/api-types/capabilities` exports `CapabilityId`, the open union derived from the `CapabilityIdRegistry` inte…
|
||||
- [capability-id](./src/capability-id/capability-id.md) — `@suaveplan/api-types/capabilities` exports `CapabilityId`, the open union derived from the `CapabilityIdRegistry` inte…
|
||||
- [chain](./src/chain/chain.md) — The chain module closes the gap between the inventory-side contract (`TenantProvider`, owned by this package) and the p…
|
||||
- [chain](./src/chain/chain.md) — The chain module closes the gap between the inventory-side contract (`TenantProvider`, owned by this package) and the p…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/api-tenant-provider` raises every failure through a single `SuaveplanError` subclass — `TenantProviderError…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/api-tenant-provider` raises every failure through a single `SuaveplanError` subclass — `TenantProviderError…
|
||||
- [pagination](./src/pagination/pagination.md) — `TenantProvider.list(opts?)` is `AsyncIterable<TenantRecord>` — callers iterate one tenant at a time without caring whe…
|
||||
- [pagination](./src/pagination/pagination.md) — `TenantProvider.list(opts?)` is `AsyncIterable<TenantRecord>` — callers iterate one tenant at a time without caring whe…
|
||||
- [provider](./src/provider/provider.md) — `TenantProvider` is the abstract contract every concrete tenant source implements. The API plugin host consumes it for…
|
||||
- [provider](./src/provider/provider.md) — `TenantProvider` is the abstract contract every concrete tenant source implements. The API plugin host consumes it for…
|
||||
- [tenant-record](./src/tenant-record/tenant-record.md) — A `TenantRecord` is one row from the host's tenant inventory. Every concrete `TenantProvider` — whether it reads from m…
|
||||
- [tenant-record](./src/tenant-record/tenant-record.md) — A `TenantRecord` is one row from the host's tenant inventory. Every concrete `TenantProvider` — whether it reads from m…
|
||||
- [testing](./src/testing/conformance.md) — Every concrete `TenantProvider` — the in-memory reference impl (`api-tenant-provider-memory`, ), the host-DB-bac…
|
||||
- [conformance](./src/testing/conformance.md) — Every concrete `TenantProvider` — the in-memory reference impl (`api-tenant-provider-memory`, ), the host-DB-bac…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What ships
|
||||
|
||||
| Subpath | Module | Doc |
|
||||
|---|---|---|
|
||||
| `./provider` | `TenantProvider` interface — `list / count / lookup / dispose` | [`src/provider/provider.md`](src/provider/provider.md) |
|
||||
| `./tenant-record` | `TenantRecord` + `tenantRecordSchema` (strict Zod) | [`src/tenant-record/tenant-record.md`](src/tenant-record/tenant-record.md) |
|
||||
| `./pagination` | `ListOpts`, `encodeCursor` / `decodeCursor` | [`src/pagination/pagination.md`](src/pagination/pagination.md) |
|
||||
| `./capability-id` | `TENANT_PROVIDER_CAPABILITY_ID` + widened `CapabilityId` | [`src/capability-id/capability-id.md`](src/capability-id/capability-id.md) |
|
||||
| `./error-codes` | `TENANT_PROVIDER_CODES` + `TenantProviderError` | [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) |
|
||||
| `./testing` | `runTenantProviderConformance(opts)` | [`src/testing/conformance.md`](src/testing/conformance.md) |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Concrete sources — `api-tenant-provider-memory` (), DB-backed sources are deployment-specific.
|
||||
- Tenant CREATE / UPDATE / DELETE — that's an admin concern.
|
||||
- Per-request tenant extraction — that's `TenantResolver` in `@suaveplan/api-types/tenant-resolver` + `@suaveplan/api-tenant-resolver-*` adapters.
|
||||
|
||||
## Why a separate package
|
||||
|
||||
`TenantProvider` answers "what tenants does this host know about" — distinct from `TenantResolver`, which answers "which tenant is making this request right now". Both contracts coexist in the api-stack but live in separate packages so adapters depend on the narrower one they need. Per design doc §5.4, the per-tenant job scheduler depends only on `TenantProvider`; HTTP middleware depends only on `TenantResolver`.
|
||||
|
||||
## Stage
|
||||
|
||||
Contracts of the API plugin system umbrella.
|
||||
|
||||
101
packages/api-stack/api/api-tenant-provider/package.json
Normal file
101
packages/api-stack/api/api-tenant-provider/package.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "@suaveplan/api-tenant-provider",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Abstract `TenantProvider` contract for the Suaveplan API plugin foundation. Enumerates the universe of tenants known to a host so per-tenant cron / queued jobs (manifest `perTenant: true`) and admin tooling can fan out one invocation per tenant. Ships the interface, the `TenantRecord` Zod schema, the `ListOpts` pagination shape, the `CapabilityId` augmentation contributing `\"tenant.provider\"`, the frozen `TENANT_PROVIDER_CODES` error registry, and a reusable `runTenantProviderConformance(factory)` harness — no concrete source (in-memory, DB, directory-service adapters land in sibling packages).",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/provider.d.ts",
|
||||
"default": "./dist/provider/provider.js"
|
||||
}
|
||||
},
|
||||
"./tenant-record": {
|
||||
"import": {
|
||||
"types": "./dist/tenant-record/tenant-record.d.ts",
|
||||
"default": "./dist/tenant-record/tenant-record.js"
|
||||
}
|
||||
},
|
||||
"./pagination": {
|
||||
"import": {
|
||||
"types": "./dist/pagination/pagination.d.ts",
|
||||
"default": "./dist/pagination/pagination.js"
|
||||
}
|
||||
},
|
||||
"./capability-id": {
|
||||
"import": {
|
||||
"types": "./dist/capability-id/capability-id.d.ts",
|
||||
"default": "./dist/capability-id/capability-id.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/conformance.d.ts",
|
||||
"default": "./dist/testing/conformance.js"
|
||||
}
|
||||
},
|
||||
"./chain": {
|
||||
"import": {
|
||||
"types": "./dist/chain/chain.d.ts",
|
||||
"default": "./dist/chain/chain.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# `"tenant.provider"` — capability id
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-types/capabilities` exports `CapabilityId`, the open
|
||||
union derived from the `CapabilityIdRegistry` interface. As of the
|
||||
Stage-1 coherence fixes the foundation registry includes
|
||||
`"tenant.provider"` directly, so this package no longer maintains a
|
||||
widening alias — the runtime constant lives here because the
|
||||
`TenantProvider` interface lives here, and host code needs a stable
|
||||
sentinel without re-typing the literal everywhere.
|
||||
|
||||
The module re-exports the canonical `CapabilityId` from api-types for
|
||||
source compatibility with consumers that historically imported it from
|
||||
this package.
|
||||
|
||||
## Features
|
||||
|
||||
- Runtime constant `TENANT_PROVIDER_CAPABILITY_ID = "tenant.provider"`.
|
||||
- Type alias `TenantProviderCapabilityId = typeof TENANT_PROVIDER_CAPABILITY_ID`.
|
||||
- Re-export of `CapabilityId` from `@suaveplan/api-types/capabilities` — source-compatible with callers that imported the widened alias previously.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TENANT_PROVIDER_CAPABILITY_ID } from "@suaveplan/api-tenant-provider/capability-id";
|
||||
|
||||
if (manifest.requires?.includes(TENANT_PROVIDER_CAPABILITY_ID)) {
|
||||
// The plugin opted into per-tenant fan-out.
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
type CapabilityId,
|
||||
TENANT_PROVIDER_CAPABILITY_ID,
|
||||
} from "@suaveplan/api-tenant-provider/capability-id";
|
||||
|
||||
// A capability-checker that accepts both foundation and tenant-provider ids.
|
||||
function describeCapability(id: CapabilityId): string {
|
||||
switch (id) {
|
||||
case TENANT_PROVIDER_CAPABILITY_ID:
|
||||
return "tenant enumeration (per-tenant cron fan-out)";
|
||||
case "db":
|
||||
return "tenant-scoped database adapter";
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const TENANT_PROVIDER_CAPABILITY_ID = "tenant.provider" as const`.
|
||||
- `type TenantProviderCapabilityId = "tenant.provider"`.
|
||||
- `type CapabilityId` — re-export of the canonical union from `@suaveplan/api-types/capabilities`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Foundation `CapabilityId` is `keyof CapabilityIdRegistry`, and the
|
||||
foundation registry already contributes `"tenant.provider"` directly,
|
||||
so a separate widening alias here would be redundant. Any third-party
|
||||
package adding its own capability id should extend
|
||||
`CapabilityIdRegistry` from `@suaveplan/api-types/capabilities` via
|
||||
`declare module` rather than re-introducing a widened type union — the
|
||||
registry pattern is the documented extension point.
|
||||
|
||||
The constant is `as const` so the value type narrows to the exact
|
||||
string literal `"tenant.provider"`, which is what makes
|
||||
`expectTypeOf<typeof TENANT_PROVIDER_CAPABILITY_ID>().toEqualTypeOf<TenantProviderCapabilityId>()`
|
||||
hold at the type level.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type CapabilityId,
|
||||
TENANT_PROVIDER_CAPABILITY_ID,
|
||||
type TenantProviderCapabilityId,
|
||||
} from "./capability-id.js";
|
||||
|
||||
describe("TENANT_PROVIDER_CAPABILITY_ID", () => {
|
||||
it('is the literal "tenant.provider"', { timeout: 5000 }, () => {
|
||||
expect(TENANT_PROVIDER_CAPABILITY_ID).toBe("tenant.provider");
|
||||
});
|
||||
|
||||
it("narrows as a string literal at the type level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const id: TenantProviderCapabilityId = TENANT_PROVIDER_CAPABILITY_ID;
|
||||
const widened: CapabilityId = id;
|
||||
expect(widened).toBe("tenant.provider");
|
||||
});
|
||||
|
||||
it("widened CapabilityId accepts both base and tenant.provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// `db` is part of api-types' base CapabilityId union, so the
|
||||
// widened alias must still accept it.
|
||||
const fromBase: CapabilityId = "db";
|
||||
const fromExtension: CapabilityId = TENANT_PROVIDER_CAPABILITY_ID;
|
||||
expect(fromBase).toBe("db");
|
||||
expect(fromExtension).toBe("tenant.provider");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Runtime sentinel identifying this capability — string-keyed so it
|
||||
* survives type erasure and is safe to log / pass through wire formats.
|
||||
*
|
||||
* Plugins that declare `perTenant: true` jobs in their manifest
|
||||
* implicitly require this capability — the host resolves it
|
||||
* automatically — but the literal MUST exist as a value so
|
||||
* manifest-shape checks and host-side lookups can compare against it.
|
||||
*/
|
||||
export const TENANT_PROVIDER_CAPABILITY_ID = "tenant.provider" as const;
|
||||
|
||||
export type TenantProviderCapabilityId = typeof TENANT_PROVIDER_CAPABILITY_ID;
|
||||
|
||||
/**
|
||||
* Re-export of the canonical {@link
|
||||
* import("@suaveplan/api-types/capabilities").CapabilityId} union.
|
||||
*
|
||||
* As of the Stage-1 coherence fixes, `"tenant.provider"` is part of the
|
||||
* `CapabilityIdRegistry` shipped by `@suaveplan/api-types/capabilities`
|
||||
* directly, so this package no longer needs a widening alias. The
|
||||
* re-export remains for source compatibility with consumers that
|
||||
* imported `CapabilityId` from `@suaveplan/api-tenant-provider`.
|
||||
*/
|
||||
export type { CapabilityId } from "@suaveplan/api-types/capabilities";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CapabilityId,
|
||||
TENANT_PROVIDER_CAPABILITY_ID,
|
||||
type TenantProviderCapabilityId,
|
||||
} from "./capability-id.js";
|
||||
128
packages/api-stack/api/api-tenant-provider/src/chain/chain.md
Normal file
128
packages/api-stack/api/api-tenant-provider/src/chain/chain.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# `chain` — `TenantResolverChain` factory backed by a `TenantProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The chain module closes the gap between the inventory-side contract
|
||||
(`TenantProvider`, owned by this package) and the per-request contract
|
||||
(`TenantResolver` / `TenantResolverChain`, owned by
|
||||
`@suaveplan/api-types/tenant-resolver`). Hosts that already have a
|
||||
`TenantProvider` instance no longer need to hand-roll the
|
||||
`header -> provider.lookup -> TenantContext` chain at the boundary of
|
||||
every fixture or server file — they import
|
||||
`tenantResolverChainFromProvider(provider, opts?)` and wire the result
|
||||
straight into `HostAdapters.tenant`.
|
||||
|
||||
`recordToContext` is exposed alongside so downstream packages
|
||||
(`api-tenant-provider-postgres`, custom resolvers) can map a
|
||||
`TenantRecord` to a `TenantContext` without reimplementing the four-line
|
||||
projection (and crucially without re-introducing the `slug = String(id)`
|
||||
synthesis bug that motivated Gap #2).
|
||||
|
||||
## Features
|
||||
|
||||
- Three built-in strategies tried in fixed order: `X-Tenant-Id` header
|
||||
(configurable via `headerName`), JWT claim (when
|
||||
`jwtClaimExtractor` is supplied), and subdomain (when `subdomainBase`
|
||||
is supplied — e.g. `subdomainBase: "example.com"` matches
|
||||
`tenant-1.example.com`).
|
||||
- First match wins; misses (no matching strategy or `provider.lookup`
|
||||
returns `null`) advance to the next strategy.
|
||||
- Optional LRU memoisation on tenant-id -> `TenantContext` keyed by
|
||||
`{ maxEntries, ttlMs }`.
|
||||
- Zero `slug` synthesis — slug comes off the inventory row directly via
|
||||
`recordToContext`, which is the canonical projection.
|
||||
- Pure functions plus a single closure-scoped cache; no module-level
|
||||
state. Safe to instantiate one chain per host.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createMemoryTenantProvider } from "@suaveplan/api-tenant-provider-memory";
|
||||
import { tenantResolverChainFromProvider } from "@suaveplan/api-tenant-provider/chain";
|
||||
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [
|
||||
{
|
||||
id: "t1",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
createdAt: "2026-05-26T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const chain = tenantResolverChainFromProvider(provider);
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/", { headers: { "X-Tenant-Id": "t1" } })
|
||||
);
|
||||
// => { id: "t1", slug: "acme" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { tenantResolverChainFromProvider } from "@suaveplan/api-tenant-provider/chain";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
|
||||
declare const provider: import("@suaveplan/api-tenant-provider/provider").TenantProvider;
|
||||
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
headerName: "X-Custom-Tenant",
|
||||
subdomainBase: "example.com",
|
||||
jwtClaimExtractor: (req) => {
|
||||
const principal = (req as unknown as { principal?: Principal | null })
|
||||
.principal;
|
||||
return principal?.tenantId ?? null;
|
||||
},
|
||||
cache: { maxEntries: 1024, ttlMs: 60_000 },
|
||||
});
|
||||
|
||||
// `chain.resolvers` exposes the configured order — useful for diagnostics
|
||||
// and for adapter-level instrumentation that wants to record which
|
||||
// resolver hit.
|
||||
for (const r of chain.resolvers) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(r.id);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function recordToContext(record: TenantRecord): TenantContext` — drops
|
||||
`status`/`createdAt`, preserves `id`/`slug`/optional `displayName`/optional
|
||||
`metadata`.
|
||||
- `type JwtClaimExtractor = (request: Request) => string | null` — pulls a
|
||||
tenant-id-shaped claim out of a request; returning `null` abstains.
|
||||
- `interface ChainCacheOptions { readonly maxEntries: number; readonly ttlMs: number }`.
|
||||
- `interface TenantResolverChainFromProviderOptions { headerName?, subdomainBase?, jwtClaimExtractor?, cache? }`.
|
||||
- `function tenantResolverChainFromProvider(provider, opts?): TenantResolverChain` —
|
||||
returns `{ resolvers, resolve(req) }` matching the
|
||||
`@suaveplan/api-types/tenant-resolver` contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The chain order is fixed by the factory because mixing strategies via a
|
||||
freely-ordered array invites caller-side bugs (subdomain-before-header
|
||||
flips precedence at scale). Hosts that need a different order compose
|
||||
their own `TenantResolverChain` directly — the same record-to-context
|
||||
projection (`recordToContext`) is exposed for that case.
|
||||
|
||||
Subdomain matching is conservative: the leading segment must be a single
|
||||
DNS label (no dots), the host suffix must equal `.${base}`, and an exact
|
||||
match against the base alone yields `null` (so `example.com` -> no
|
||||
tenant, but `acme.example.com` -> `acme`).
|
||||
|
||||
LRU eviction is implemented over a plain `Map`. `Map`'s insertion-order
|
||||
semantics give O(1) oldest-entry removal via `keys().next().value` plus
|
||||
`delete`. The cache stores `TenantContext | null` so a miss is still
|
||||
memoised — repeated lookups of an unknown tenant do not re-hit the
|
||||
provider until the TTL expires.
|
||||
|
||||
The factory never throws — every strategy resolves to `null` on miss.
|
||||
Misconfiguration that should fail loudly (e.g. an empty `subdomainBase`)
|
||||
must be surfaced by the caller; the chain is intentionally a
|
||||
fail-soft pipeline so a bad strategy doesn't blackhole every request.
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { ListOpts } from "../pagination/pagination.js";
|
||||
import type { TenantProvider } from "../provider/provider.js";
|
||||
import type { TenantRecord } from "../tenant-record/tenant-record.js";
|
||||
import { recordToContext, tenantResolverChainFromProvider } from "./chain.js";
|
||||
|
||||
const tid = (s: string) => s as unknown as TenantId;
|
||||
|
||||
function record(
|
||||
overrides: Partial<TenantRecord> & { id: string; slug: string }
|
||||
): TenantRecord {
|
||||
return {
|
||||
id: tid(overrides.id),
|
||||
slug: overrides.slug,
|
||||
status: overrides.status ?? "active",
|
||||
createdAt: overrides.createdAt ?? "2026-05-26T00:00:00.000Z",
|
||||
...(overrides.displayName !== undefined
|
||||
? { displayName: overrides.displayName }
|
||||
: {}),
|
||||
...(overrides.metadata !== undefined
|
||||
? { metadata: overrides.metadata }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
class CountingProvider implements TenantProvider {
|
||||
public lookupCalls = 0;
|
||||
public constructor(private readonly records: readonly TenantRecord[]) {}
|
||||
public async *list(_opts?: ListOpts): AsyncIterable<TenantRecord> {
|
||||
for (const r of this.records) yield r;
|
||||
}
|
||||
public count(): Promise<number> {
|
||||
return Promise.resolve(
|
||||
this.records.filter((r) => r.status === "active").length
|
||||
);
|
||||
}
|
||||
public lookup(id: TenantId): Promise<TenantRecord | null> {
|
||||
this.lookupCalls += 1;
|
||||
return Promise.resolve(this.records.find((r) => r.id === id) ?? null);
|
||||
}
|
||||
public dispose(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("recordToContext", () => {
|
||||
it("preserves id and slug, drops status and createdAt", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = recordToContext(record({ id: "t1", slug: "acme" }));
|
||||
expect(ctx).toEqual({ id: tid("t1"), slug: "acme" });
|
||||
});
|
||||
|
||||
it("preserves displayName when present", { timeout: 5000 }, () => {
|
||||
const ctx = recordToContext(
|
||||
record({ id: "t1", slug: "acme", displayName: "Acme Co" })
|
||||
);
|
||||
expect(ctx).toEqual({
|
||||
id: tid("t1"),
|
||||
slug: "acme",
|
||||
displayName: "Acme Co",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves metadata when present", { timeout: 5000 }, () => {
|
||||
const ctx = recordToContext(
|
||||
record({
|
||||
id: "t1",
|
||||
slug: "acme",
|
||||
metadata: { tier: "gold" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toEqual({
|
||||
id: tid("t1"),
|
||||
slug: "acme",
|
||||
metadata: { tier: "gold" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves both displayName and metadata together", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = recordToContext(
|
||||
record({
|
||||
id: "t1",
|
||||
slug: "acme",
|
||||
displayName: "Acme",
|
||||
metadata: { tier: "gold" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toEqual({
|
||||
id: tid("t1"),
|
||||
slug: "acme",
|
||||
displayName: "Acme",
|
||||
metadata: { tier: "gold" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenantResolverChainFromProvider — header strategy", () => {
|
||||
it("resolves via X-Tenant-Id header (default)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "t1", slug: "acme" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider);
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": "t1" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toEqual({ id: tid("t1"), slug: "acme" });
|
||||
});
|
||||
|
||||
it("honours custom headerName", { timeout: 5000 }, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "t2", slug: "acme2" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
headerName: "X-Custom-Tenant",
|
||||
});
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Custom-Tenant": "t2" },
|
||||
})
|
||||
);
|
||||
expect(ctx?.slug).toBe("acme2");
|
||||
});
|
||||
|
||||
it("returns null when header missing", { timeout: 5000 }, async () => {
|
||||
const provider = new CountingProvider([]);
|
||||
const chain = tenantResolverChainFromProvider(provider);
|
||||
const ctx = await chain.resolve(new Request("https://api.example/x"));
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when header is whitespace-only", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([]);
|
||||
const chain = tenantResolverChainFromProvider(provider);
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": " " },
|
||||
})
|
||||
);
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when provider.lookup returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([]);
|
||||
const chain = tenantResolverChainFromProvider(provider);
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": "missing" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenantResolverChainFromProvider — JWT-claim strategy", () => {
|
||||
it("resolves via jwtClaimExtractor when header absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "tjwt", slug: "jwt-tenant" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
jwtClaimExtractor: () => "tjwt",
|
||||
});
|
||||
const ctx = await chain.resolve(new Request("https://api.example/x"));
|
||||
expect(ctx?.slug).toBe("jwt-tenant");
|
||||
});
|
||||
|
||||
it("abstains when extractor returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "tjwt", slug: "jwt-tenant" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
jwtClaimExtractor: () => null,
|
||||
});
|
||||
const ctx = await chain.resolve(new Request("https://api.example/x"));
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("header wins over JWT when both present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "t1", slug: "header-slug" }),
|
||||
record({ id: "t2", slug: "jwt-slug" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
jwtClaimExtractor: () => "t2",
|
||||
});
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": "t1" },
|
||||
})
|
||||
);
|
||||
expect(ctx?.slug).toBe("header-slug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenantResolverChainFromProvider — subdomain strategy", () => {
|
||||
it("extracts tenant from subdomain", { timeout: 5000 }, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "acme", slug: "acme" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
subdomainBase: "example.com",
|
||||
});
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://acme.example.com/x", {
|
||||
headers: { host: "acme.example.com" },
|
||||
})
|
||||
);
|
||||
expect(ctx?.slug).toBe("acme");
|
||||
});
|
||||
|
||||
it("returns null when host equals base exactly", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([record({ id: "x", slug: "x" })]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
subdomainBase: "example.com",
|
||||
});
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://example.com/x", {
|
||||
headers: { host: "example.com" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when host has unrelated suffix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([record({ id: "x", slug: "x" })]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
subdomainBase: "example.com",
|
||||
});
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://acme.other.org/x", {
|
||||
headers: { host: "acme.other.org" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects multi-segment subdomain (must be single label)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "foo.bar", slug: "x" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
subdomainBase: "example.com",
|
||||
});
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://foo.bar.example.com/x", {
|
||||
headers: { host: "foo.bar.example.com" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no host header", { timeout: 5000 }, async () => {
|
||||
const provider = new CountingProvider([record({ id: "x", slug: "x" })]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
subdomainBase: "example.com",
|
||||
});
|
||||
// Browser fetch may strip host; simulate by deleting.
|
||||
const req = new Request("https://acme.example.com/x");
|
||||
req.headers.delete("host");
|
||||
const ctx = await chain.resolve(req);
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it("strips port from host", { timeout: 5000 }, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "acme", slug: "acme" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
subdomainBase: "example.com",
|
||||
});
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://acme.example.com:8080/x", {
|
||||
headers: { host: "acme.example.com:8080" },
|
||||
})
|
||||
);
|
||||
expect(ctx?.slug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenantResolverChainFromProvider — caching", () => {
|
||||
it("memoises lookups for the same tenant id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "t1", slug: "acme" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
cache: { maxEntries: 10, ttlMs: 60_000 },
|
||||
});
|
||||
const req = () =>
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": "t1" },
|
||||
});
|
||||
await chain.resolve(req());
|
||||
await chain.resolve(req());
|
||||
await chain.resolve(req());
|
||||
expect(provider.lookupCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("caches misses too", { timeout: 5000 }, async () => {
|
||||
const provider = new CountingProvider([]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
cache: { maxEntries: 10, ttlMs: 60_000 },
|
||||
});
|
||||
const req = () =>
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": "unknown" },
|
||||
});
|
||||
await chain.resolve(req());
|
||||
await chain.resolve(req());
|
||||
expect(provider.lookupCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("evicts oldest entry beyond maxEntries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({ id: "a", slug: "a" }),
|
||||
record({ id: "b", slug: "b" }),
|
||||
record({ id: "c", slug: "c" }),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
cache: { maxEntries: 2, ttlMs: 60_000 },
|
||||
});
|
||||
const get = (id: string) =>
|
||||
chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": id },
|
||||
})
|
||||
);
|
||||
await get("a");
|
||||
await get("b");
|
||||
await get("c"); // evicts "a"
|
||||
await get("a"); // miss — re-fetched
|
||||
expect(provider.lookupCalls).toBe(4);
|
||||
});
|
||||
|
||||
it("re-fetches after ttlMs expiry", { timeout: 5000 }, async () => {
|
||||
const provider = new CountingProvider([record({ id: "a", slug: "a" })]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
cache: { maxEntries: 10, ttlMs: 1 },
|
||||
});
|
||||
const get = () =>
|
||||
chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": "a" },
|
||||
})
|
||||
);
|
||||
await get();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await get();
|
||||
expect(provider.lookupCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenantResolverChainFromProvider — round trip", () => {
|
||||
it("register-via-provider -> resolve-via-header round trip", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider([
|
||||
record({
|
||||
id: "t-rt",
|
||||
slug: "round-trip",
|
||||
displayName: "Round Trip",
|
||||
metadata: { tier: "gold" },
|
||||
}),
|
||||
]);
|
||||
const chain = tenantResolverChainFromProvider(provider);
|
||||
const ctx = await chain.resolve(
|
||||
new Request("https://api.example/x", {
|
||||
headers: { "X-Tenant-Id": "t-rt" },
|
||||
})
|
||||
);
|
||||
expect(ctx).toEqual({
|
||||
id: tid("t-rt"),
|
||||
slug: "round-trip",
|
||||
displayName: "Round Trip",
|
||||
metadata: { tier: "gold" },
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the configured resolver order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new CountingProvider([]);
|
||||
const chain = tenantResolverChainFromProvider(provider, {
|
||||
jwtClaimExtractor: () => null,
|
||||
subdomainBase: "example.com",
|
||||
});
|
||||
expect(chain.resolvers.map((r) => r.id)).toEqual([
|
||||
"header",
|
||||
"jwt-claim",
|
||||
"subdomain",
|
||||
]);
|
||||
});
|
||||
});
|
||||
230
packages/api-stack/api/api-tenant-provider/src/chain/chain.ts
Normal file
230
packages/api-stack/api/api-tenant-provider/src/chain/chain.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { TenantContext, TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type {
|
||||
TenantResolver,
|
||||
TenantResolverChain,
|
||||
} from "@suaveplan/api-types/tenant-resolver";
|
||||
import type { TenantProvider } from "../provider/provider.js";
|
||||
import type { TenantRecord } from "../tenant-record/tenant-record.js";
|
||||
|
||||
/**
|
||||
* Convert a {@link TenantRecord} (the inventory row shape) into the
|
||||
* per-request {@link TenantContext} shape.
|
||||
*
|
||||
* Drops `status` and `createdAt` (inventory-only fields), preserves `id`,
|
||||
* `slug`, optional `displayName`, and optional `metadata`. This is the
|
||||
* canonical bridge between `@suaveplan/api-tenant-provider`'s inventory
|
||||
* contract and `@suaveplan/api-types/tenant`'s per-request contract; both
|
||||
* the chain factory below and downstream consumers use it.
|
||||
*/
|
||||
export function recordToContext(record: TenantRecord): TenantContext {
|
||||
const ctx: TenantContext =
|
||||
record.displayName !== undefined
|
||||
? record.metadata !== undefined
|
||||
? {
|
||||
id: record.id,
|
||||
slug: record.slug,
|
||||
displayName: record.displayName,
|
||||
metadata: record.metadata,
|
||||
}
|
||||
: {
|
||||
id: record.id,
|
||||
slug: record.slug,
|
||||
displayName: record.displayName,
|
||||
}
|
||||
: record.metadata !== undefined
|
||||
? {
|
||||
id: record.id,
|
||||
slug: record.slug,
|
||||
metadata: record.metadata,
|
||||
}
|
||||
: { id: record.id, slug: record.slug };
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional JWT-claim extractor — pulled out of a request by an upstream
|
||||
* authentication adapter. Return `null` to abstain.
|
||||
*/
|
||||
export type JwtClaimExtractor = (request: Request) => string | null;
|
||||
|
||||
/**
|
||||
* Per-request memoisation cache. The factory creates one
|
||||
* `Map<Request, TenantContext | null>` keyed weakly by the request — when
|
||||
* the request is GC'd the entry is dropped. With a small TTL/maxEntries
|
||||
* eviction policy, hot keys remain even across separate requests.
|
||||
*/
|
||||
export interface ChainCacheOptions {
|
||||
readonly maxEntries: number;
|
||||
readonly ttlMs: number;
|
||||
}
|
||||
|
||||
/** Options for {@link tenantResolverChainFromProvider}. */
|
||||
export interface TenantResolverChainFromProviderOptions {
|
||||
/** Header name; default `"X-Tenant-Id"`. Case-insensitive lookup. */
|
||||
readonly headerName?: string;
|
||||
/**
|
||||
* Subdomain base — when set, the chain strips this suffix from the
|
||||
* request's `Host` header and treats the leading segment as the
|
||||
* tenant id (or slug, looked up via {@link TenantProvider.lookup}).
|
||||
* E.g. `subdomainBase: "example.com"` matches host
|
||||
* `tenant-1.example.com` -> tenant id `"tenant-1"`.
|
||||
*/
|
||||
readonly subdomainBase?: string;
|
||||
/**
|
||||
* JWT-claim extractor. Must read an already-resolved claim off the
|
||||
* request (typically attached by an upstream auth chain) and return
|
||||
* the tenant id string, or `null` to abstain.
|
||||
*/
|
||||
readonly jwtClaimExtractor?: JwtClaimExtractor;
|
||||
/**
|
||||
* Per-tenant-id memoisation. When provided, lookups are cached for
|
||||
* `ttlMs` with at most `maxEntries` entries (LRU eviction).
|
||||
*/
|
||||
readonly cache?: ChainCacheOptions;
|
||||
}
|
||||
|
||||
const DEFAULT_HEADER_NAME = "X-Tenant-Id";
|
||||
|
||||
interface CacheEntry {
|
||||
readonly context: TenantContext | null;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
class LruCache {
|
||||
private readonly store = new Map<string, CacheEntry>();
|
||||
public constructor(
|
||||
private readonly maxEntries: number,
|
||||
private readonly ttlMs: number
|
||||
) {}
|
||||
public get(key: string, now: number): TenantContext | null | undefined {
|
||||
const entry = this.store.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
if (entry.expiresAt <= now) {
|
||||
this.store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
// refresh LRU position
|
||||
this.store.delete(key);
|
||||
this.store.set(key, entry);
|
||||
return entry.context;
|
||||
}
|
||||
public set(key: string, value: TenantContext | null, now: number): void {
|
||||
if (this.store.size >= this.maxEntries) {
|
||||
// size > 0 here so keys().next() always yields a defined value
|
||||
const oldest = this.store.keys().next().value as string;
|
||||
this.store.delete(oldest);
|
||||
}
|
||||
this.store.set(key, { context: value, expiresAt: now + this.ttlMs });
|
||||
}
|
||||
}
|
||||
|
||||
function extractFromHeader(
|
||||
request: Request,
|
||||
headerName: string
|
||||
): string | null {
|
||||
const value = request.headers.get(headerName);
|
||||
if (value === null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
function extractFromSubdomain(request: Request, base: string): string | null {
|
||||
const host = request.headers.get("host");
|
||||
if (host === null) return null;
|
||||
// String.prototype.split always returns at least one element, so [0] is defined.
|
||||
const bareHost = (host.split(":")[0] as string).toLowerCase();
|
||||
const baseLower = base.toLowerCase();
|
||||
if (bareHost === baseLower) return null;
|
||||
const suffix = `.${baseLower}`;
|
||||
if (!bareHost.endsWith(suffix)) return null;
|
||||
const leading = bareHost.slice(0, bareHost.length - suffix.length);
|
||||
if (leading.length === 0 || leading.includes(".")) return null;
|
||||
return leading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link TenantResolverChain} backed by a {@link TenantProvider}.
|
||||
*
|
||||
* Strategies are tried in this fixed order; the first that produces a
|
||||
* non-null tenant id wins:
|
||||
*
|
||||
* 1. `X-Tenant-Id` header (or `opts.headerName`)
|
||||
* 2. JWT claim via `opts.jwtClaimExtractor`
|
||||
* 3. Subdomain (when `opts.subdomainBase` is set)
|
||||
*
|
||||
* Each strategy resolves a tenant-id string, then `provider.lookup(id)`
|
||||
* fetches the {@link TenantRecord}. If the lookup returns `null`, the
|
||||
* chain advances to the next strategy. The first record found is mapped
|
||||
* to a {@link TenantContext} via {@link recordToContext}. When no
|
||||
* strategy hits, the chain resolves to `null`.
|
||||
*
|
||||
* Optional per-tenant-id memoisation (`opts.cache`) collapses repeated
|
||||
* inventory lookups for the same tenant id within the TTL.
|
||||
*/
|
||||
export function tenantResolverChainFromProvider(
|
||||
provider: TenantProvider,
|
||||
opts: TenantResolverChainFromProviderOptions = {}
|
||||
): TenantResolverChain {
|
||||
const headerName = opts.headerName ?? DEFAULT_HEADER_NAME;
|
||||
const subdomainBase = opts.subdomainBase;
|
||||
const jwtClaimExtractor = opts.jwtClaimExtractor;
|
||||
const cache =
|
||||
opts.cache !== undefined
|
||||
? new LruCache(opts.cache.maxEntries, opts.cache.ttlMs)
|
||||
: null;
|
||||
|
||||
async function lookupContext(
|
||||
tenantId: string
|
||||
): Promise<TenantContext | null> {
|
||||
if (cache !== null) {
|
||||
const cached = cache.get(tenantId, Date.now());
|
||||
if (cached !== undefined) return cached;
|
||||
}
|
||||
const record = await provider.lookup(tenantId as TenantId);
|
||||
const ctx = record === null ? null : recordToContext(record);
|
||||
if (cache !== null) cache.set(tenantId, ctx, Date.now());
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const resolvers: TenantResolver[] = [
|
||||
{
|
||||
id: "header",
|
||||
async resolve(req: Request): Promise<TenantContext | null> {
|
||||
const id = extractFromHeader(req, headerName);
|
||||
if (id === null) return null;
|
||||
return lookupContext(id);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (jwtClaimExtractor !== undefined) {
|
||||
resolvers.push({
|
||||
id: "jwt-claim",
|
||||
async resolve(req: Request): Promise<TenantContext | null> {
|
||||
const id = jwtClaimExtractor(req);
|
||||
if (id === null) return null;
|
||||
return lookupContext(id);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (subdomainBase !== undefined) {
|
||||
resolvers.push({
|
||||
id: "subdomain",
|
||||
async resolve(req: Request): Promise<TenantContext | null> {
|
||||
const id = extractFromSubdomain(req, subdomainBase);
|
||||
if (id === null) return null;
|
||||
return lookupContext(id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resolvers,
|
||||
async resolve(req: Request): Promise<TenantContext | null> {
|
||||
for (const r of resolvers) {
|
||||
const ctx = await r.resolve(req);
|
||||
if (ctx !== null) return ctx;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ChainCacheOptions,
|
||||
type JwtClaimExtractor,
|
||||
recordToContext,
|
||||
type TenantResolverChainFromProviderOptions,
|
||||
tenantResolverChainFromProvider,
|
||||
} from "./chain.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# `TENANT_PROVIDER_CODES` — stable error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-tenant-provider` raises every failure through a single
|
||||
`SuaveplanError` subclass — `TenantProviderError` — and every instance
|
||||
carries a code from the frozen `TENANT_PROVIDER_CODES` registry. The
|
||||
registry exists so consumers can branch on a stable string literal
|
||||
(`error.code === "TENANT_PROVIDER.INVALID_CURSOR"`) without coupling
|
||||
to the package's class hierarchy or sniffing message text.
|
||||
|
||||
Because tenant enumeration is consumed by very different concerns
|
||||
(per-tenant cron fan-out, admin dashboards, background reconciliation,
|
||||
cache warmers), a small set of well-named codes is more useful than a
|
||||
large catalogue: callers either retry on `SOURCE_UNAVAILABLE`, fix
|
||||
caller bugs on `INVALID_CURSOR`, or treat the absent record as expected
|
||||
on `TENANT_NOT_FOUND`.
|
||||
|
||||
## Features
|
||||
|
||||
- `defineCodes("TENANT_PROVIDER", [...])` produces a frozen registry; new entries are an OpenSpec change.
|
||||
- `TenantProviderError extends SuaveplanError<TenantProviderErrorCode>` so `instanceof TenantProviderError` narrows the `code` field.
|
||||
- Three codes: `TENANT_NOT_FOUND`, `SOURCE_UNAVAILABLE`, `INVALID_CURSOR`.
|
||||
- Inherits cause-chain, redaction, OTel span-event adapter from `@suaveplan/error`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
TENANT_PROVIDER_CODES,
|
||||
TenantProviderError,
|
||||
} from "@suaveplan/api-tenant-provider/error-codes";
|
||||
|
||||
throw new TenantProviderError(
|
||||
TENANT_PROVIDER_CODES.TENANT_NOT_FOUND,
|
||||
"tenant t-42 is not registered",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
TenantProviderError,
|
||||
TENANT_PROVIDER_CODES,
|
||||
} from "@suaveplan/api-tenant-provider/error-codes";
|
||||
|
||||
try {
|
||||
for await (const t of provider.list({ cursor })) {
|
||||
// ...
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof TenantProviderError &&
|
||||
err.code === TENANT_PROVIDER_CODES.INVALID_CURSOR
|
||||
) {
|
||||
// The caller passed a cursor not produced by `list()` — reset
|
||||
// and start a fresh iteration instead of crashing the job.
|
||||
for await (const t of provider.list()) {
|
||||
// ...
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_PROVIDER_CODES`: `Readonly<{ TENANT_NOT_FOUND: "TENANT_PROVIDER.TENANT_NOT_FOUND"; SOURCE_UNAVAILABLE: "TENANT_PROVIDER.SOURCE_UNAVAILABLE"; INVALID_CURSOR: "TENANT_PROVIDER.INVALID_CURSOR" }>`.
|
||||
- `TenantProviderErrorCode`: the union of those three string literals.
|
||||
- `class TenantProviderError extends SuaveplanError<TenantProviderErrorCode>` with `constructor(code, message, options?)` and public `readonly tenantProviderCode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are defined via `defineCodes("TENANT_PROVIDER", [...] as const)`
|
||||
so the `TENANT_PROVIDER.` prefix is forwarded by the registry helper —
|
||||
the array entries are bare names. The resulting object is frozen at
|
||||
construction and the type is widened to the union of every namespaced
|
||||
literal. The `tenantProviderCode` field on the error mirrors `code`
|
||||
for ergonomic narrowing in consumer code that already imports the
|
||||
class but not the code union.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TENANT_PROVIDER_CODES, TenantProviderError } from "./error-codes.js";
|
||||
|
||||
describe("TENANT_PROVIDER_CODES registry", () => {
|
||||
it("exposes exactly the three documented codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(TENANT_PROVIDER_CODES).sort()).toEqual([
|
||||
"INVALID_CURSOR",
|
||||
"SOURCE_UNAVAILABLE",
|
||||
"TENANT_NOT_FOUND",
|
||||
]);
|
||||
});
|
||||
|
||||
it("namespaces every code under TENANT_PROVIDER.*", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(TENANT_PROVIDER_CODES)) {
|
||||
expect(value.startsWith("TENANT_PROVIDER.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(TENANT_PROVIDER_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TenantProviderError", () => {
|
||||
it("captures the code on both `code` and `tenantProviderCode`", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new TenantProviderError(
|
||||
TENANT_PROVIDER_CODES.TENANT_NOT_FOUND,
|
||||
"missing"
|
||||
);
|
||||
expect(err.code).toBe("TENANT_PROVIDER.TENANT_NOT_FOUND");
|
||||
expect(err.tenantProviderCode).toBe("TENANT_PROVIDER.TENANT_NOT_FOUND");
|
||||
expect(err).toBeInstanceOf(TenantProviderError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("forwards SuaveplanErrorOptions (cause)", { timeout: 5000 }, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new TenantProviderError(
|
||||
TENANT_PROVIDER_CODES.SOURCE_UNAVAILABLE,
|
||||
"boom",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("every TENANT_PROVIDER_CODES entry is constructible", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(TENANT_PROVIDER_CODES)) {
|
||||
const err = new TenantProviderError(code, `msg ${code}`);
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-tenant-provider`.
|
||||
*
|
||||
* Every public failure path raises a {@link TenantProviderError} carrying
|
||||
* one of these namespaced `TENANT_PROVIDER.*` codes. Consumers branch on
|
||||
* the string value (e.g. `error.code === TENANT_PROVIDER_CODES.TENANT_NOT_FOUND`
|
||||
* or the inline literal `"TENANT_PROVIDER.TENANT_NOT_FOUND"`).
|
||||
*
|
||||
* The 3 codes cover:
|
||||
* - `TENANT_NOT_FOUND` — `lookup(id)` MAY return `null`, but if a caller
|
||||
* asserts existence the helper raises this.
|
||||
* - `SOURCE_UNAVAILABLE` — underlying tenant source (DB, directory service)
|
||||
* is transiently unreachable.
|
||||
* - `INVALID_CURSOR` — `ListOpts.cursor` could not be parsed as one
|
||||
* produced by `list()`.
|
||||
*/
|
||||
export const TENANT_PROVIDER_CODES = defineCodes("TENANT_PROVIDER", [
|
||||
"TENANT_NOT_FOUND",
|
||||
"SOURCE_UNAVAILABLE",
|
||||
"INVALID_CURSOR",
|
||||
] as const);
|
||||
|
||||
export type TenantProviderErrorCode =
|
||||
(typeof TENANT_PROVIDER_CODES)[keyof typeof TENANT_PROVIDER_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-tenant-provider package. Extends
|
||||
* {@link SuaveplanError} so `instanceof TenantProviderError` narrows
|
||||
* `code` to {@link TenantProviderErrorCode}.
|
||||
*/
|
||||
export class TenantProviderError extends SuaveplanError<TenantProviderErrorCode> {
|
||||
public readonly tenantProviderCode: TenantProviderErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: TenantProviderErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.tenantProviderCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
TENANT_PROVIDER_CODES,
|
||||
TenantProviderError,
|
||||
type TenantProviderErrorCode,
|
||||
} from "./error-codes.js";
|
||||
35
packages/api-stack/api/api-tenant-provider/src/index.ts
Normal file
35
packages/api-stack/api/api-tenant-provider/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export {
|
||||
type CapabilityId,
|
||||
TENANT_PROVIDER_CAPABILITY_ID,
|
||||
type TenantProviderCapabilityId,
|
||||
} from "./capability-id/index.js";
|
||||
export {
|
||||
type ChainCacheOptions,
|
||||
type JwtClaimExtractor,
|
||||
recordToContext,
|
||||
type TenantResolverChainFromProviderOptions,
|
||||
tenantResolverChainFromProvider,
|
||||
} from "./chain/index.js";
|
||||
export {
|
||||
TENANT_PROVIDER_CODES,
|
||||
TenantProviderError,
|
||||
type TenantProviderErrorCode,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
type CursorState,
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
type ListOpts,
|
||||
listOptsSchema,
|
||||
} from "./pagination/index.js";
|
||||
export type { TenantProvider } from "./provider/index.js";
|
||||
export {
|
||||
TENANT_STATUSES,
|
||||
type TenantRecord,
|
||||
type TenantStatus,
|
||||
tenantRecordSchema,
|
||||
} from "./tenant-record/index.js";
|
||||
export {
|
||||
runTenantProviderConformance,
|
||||
type TenantProviderConformanceOptions,
|
||||
} from "./testing/index.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type CursorState,
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
type ListOpts,
|
||||
listOptsSchema,
|
||||
} from "./pagination.js";
|
||||
@@ -0,0 +1,93 @@
|
||||
# `ListOpts` + opaque cursor encoding
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TenantProvider.list(opts?)` is `AsyncIterable<TenantRecord>` — callers
|
||||
iterate one tenant at a time without caring whether the underlying
|
||||
source is paging behind the scenes. `ListOpts` is the small option
|
||||
bundle that lets callers tune that behavior: page-size hint,
|
||||
include-suspended toggle, and an opaque cursor for resumption.
|
||||
|
||||
This module ships the type, the strict-mode Zod schema, and the
|
||||
canonical base64url-JSON cursor encode/decode helpers used by the
|
||||
in-memory reference implementation (Stage 4). Concrete sources are
|
||||
free to use a different cursor scheme — the contract only requires
|
||||
that a cursor produced by a `list()` call decodes back to a valid
|
||||
state when passed to the same provider's `list({ cursor })`.
|
||||
|
||||
## Features
|
||||
|
||||
- `pageSize` — positive integer hint; sources MAY honor or ignore.
|
||||
- `includeSuspended` — lifts the active-only default; never surfaces `archived`.
|
||||
- `cursor` — opaque string produced by a previous `list()` call.
|
||||
- `listOptsSchema` — strict-mode Zod; unknown keys rejected.
|
||||
- `encodeCursor` / `decodeCursor` — base64url JSON round-trip with structural validation; rejects malformed input with `TENANT_PROVIDER.INVALID_CURSOR`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { encodeCursor, decodeCursor } from "@suaveplan/api-tenant-provider/pagination";
|
||||
|
||||
const opaque = encodeCursor({ offset: 100, version: 1 });
|
||||
const state = decodeCursor(opaque);
|
||||
console.log(state.offset); // 100
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { ListOpts } from "@suaveplan/api-tenant-provider/pagination";
|
||||
import {
|
||||
TenantProviderError,
|
||||
TENANT_PROVIDER_CODES,
|
||||
} from "@suaveplan/api-tenant-provider/error-codes";
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
} from "@suaveplan/api-tenant-provider/pagination";
|
||||
|
||||
// A provider author's `list` implementation, sketched.
|
||||
async function* list(opts: ListOpts = {}): AsyncIterable<TenantRecord> {
|
||||
const pageSize = opts.pageSize ?? 50;
|
||||
let offset = 0;
|
||||
if (opts.cursor) {
|
||||
const decoded = decodeCursor(opts.cursor);
|
||||
offset = decoded.offset;
|
||||
}
|
||||
while (true) {
|
||||
const page = await fetchPage(offset, pageSize);
|
||||
for (const row of page) yield row;
|
||||
if (page.length < pageSize) return;
|
||||
offset += page.length;
|
||||
// emit a resume cursor next time `list({ cursor })` is called
|
||||
void encodeCursor({ offset, version: 1 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ListOpts { readonly pageSize?: number; readonly includeSuspended?: boolean; readonly cursor?: string }`.
|
||||
- `const listOptsSchema: z.ZodType<ListOpts>` — strict-mode; `pageSize` must be a positive integer; `cursor` must be non-empty.
|
||||
- `interface CursorState { readonly offset: number; readonly version: 1 }`.
|
||||
- `function encodeCursor(state: CursorState): string` — base64url-JSON.
|
||||
- `function decodeCursor(cursor: string): CursorState` — throws `TenantProviderError("TENANT_PROVIDER.INVALID_CURSOR", ...)` on any decode failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical cursor encoding is `base64url(JSON.stringify(state))`.
|
||||
This is deliberately readable: a developer reading a log line can
|
||||
base64url-decode the cursor and see the offset. Adversarial use
|
||||
(forging a cursor to skip authorization) is not a threat the provider
|
||||
contract defends against — the request still has to pass every
|
||||
downstream tenant-scoping check, and the provider's `list` itself is
|
||||
not a public endpoint.
|
||||
|
||||
`decodeCursor` catches both the `base64url` decode and the `JSON.parse`
|
||||
failure modes, normalising every failure to a single
|
||||
`TENANT_PROVIDER.INVALID_CURSOR` error so consumers branch on a stable
|
||||
code.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { TenantProviderError } from "../error-codes/error-codes.js";
|
||||
import { decodeCursor, encodeCursor, listOptsSchema } from "./pagination.js";
|
||||
|
||||
describe("ListOpts schema", () => {
|
||||
it("accepts an empty object", { timeout: 5000 }, () => {
|
||||
expect(listOptsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts all fields populated", { timeout: 5000 }, () => {
|
||||
const out = listOptsSchema.parse({
|
||||
pageSize: 25,
|
||||
includeSuspended: true,
|
||||
cursor: "abc",
|
||||
});
|
||||
expect(out.pageSize).toBe(25);
|
||||
expect(out.includeSuspended).toBe(true);
|
||||
expect(out.cursor).toBe("abc");
|
||||
});
|
||||
|
||||
it("rejects pageSize <= 0", { timeout: 5000 }, () => {
|
||||
expect(listOptsSchema.safeParse({ pageSize: 0 }).success).toBe(false);
|
||||
expect(listOptsSchema.safeParse({ pageSize: -1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-integer pageSize", { timeout: 5000 }, () => {
|
||||
expect(listOptsSchema.safeParse({ pageSize: 3.14 }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty cursor", { timeout: 5000 }, () => {
|
||||
expect(listOptsSchema.safeParse({ cursor: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
|
||||
expect(listOptsSchema.safeParse({ extra: 1 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor encode/decode", () => {
|
||||
it("round-trips a CursorState", { timeout: 5000 }, () => {
|
||||
const encoded = encodeCursor({ offset: 42, version: 1 });
|
||||
const decoded = decodeCursor(encoded);
|
||||
expect(decoded).toEqual({ offset: 42, version: 1 });
|
||||
});
|
||||
|
||||
it("property: every CursorState round-trips", { timeout: 5000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.nat(1_000_000), (offset) => {
|
||||
const encoded = encodeCursor({ offset, version: 1 });
|
||||
expect(decodeCursor(encoded)).toEqual({ offset, version: 1 });
|
||||
}),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty cursor string", { timeout: 5000 }, () => {
|
||||
expect(() => decodeCursor("")).toThrow(TenantProviderError);
|
||||
});
|
||||
|
||||
it("rejects a non-base64url string", { timeout: 5000 }, () => {
|
||||
// `!` is not in the base64url alphabet — when decoded as base64url
|
||||
// it produces invalid JSON; assert we surface INVALID_CURSOR.
|
||||
let captured: unknown;
|
||||
try {
|
||||
decodeCursor("!!!not-valid!!!");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(TenantProviderError);
|
||||
if (captured instanceof TenantProviderError) {
|
||||
expect(captured.code).toBe("TENANT_PROVIDER.INVALID_CURSOR");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a base64url-encoded non-JSON blob", { timeout: 5000 }, () => {
|
||||
const blob = Buffer.from("not json", "utf8").toString("base64url");
|
||||
expect(() => decodeCursor(blob)).toThrow(TenantProviderError);
|
||||
});
|
||||
|
||||
it("rejects a base64url JSON with wrong version", { timeout: 5000 }, () => {
|
||||
const blob = Buffer.from(
|
||||
JSON.stringify({ offset: 1, version: 2 }),
|
||||
"utf8"
|
||||
).toString("base64url");
|
||||
expect(() => decodeCursor(blob)).toThrow(TenantProviderError);
|
||||
});
|
||||
|
||||
it("rejects a base64url JSON with negative offset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const blob = Buffer.from(
|
||||
JSON.stringify({ offset: -1, version: 1 }),
|
||||
"utf8"
|
||||
).toString("base64url");
|
||||
expect(() => decodeCursor(blob)).toThrow(TenantProviderError);
|
||||
});
|
||||
|
||||
it("rejects a base64url JSON with extra keys (strict)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const blob = Buffer.from(
|
||||
JSON.stringify({ offset: 0, version: 1, extra: "x" }),
|
||||
"utf8"
|
||||
).toString("base64url");
|
||||
expect(() => decodeCursor(blob)).toThrow(TenantProviderError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import z from "zod";
|
||||
import { TenantProviderError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Options for `TenantProvider.list(opts?)`.
|
||||
*
|
||||
* `pageSize` is a hint to the underlying source. Sources MAY honor it
|
||||
* exactly or MAY use a different internal page size — the
|
||||
* `AsyncIterable` returned by `list` MUST yield tenants one at a time
|
||||
* regardless. `includeSuspended` lifts the active-only default to also
|
||||
* surface `suspended` tenants; `archived` is never surfaced by `list`.
|
||||
* `cursor` resumes a previous iteration — it MUST be a string produced
|
||||
* by the same provider's earlier `list()` call.
|
||||
*/
|
||||
export interface ListOpts {
|
||||
readonly pageSize?: number;
|
||||
readonly includeSuspended?: boolean;
|
||||
readonly cursor?: string;
|
||||
}
|
||||
|
||||
export const listOptsSchema: z.ZodType<ListOpts> = z
|
||||
.object({
|
||||
pageSize: z.number().int().positive().optional(),
|
||||
includeSuspended: z.boolean().optional(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
.strict() as unknown as z.ZodType<ListOpts>;
|
||||
|
||||
/**
|
||||
* Cursor payload — opaque to callers but parseable by the provider.
|
||||
*
|
||||
* Concrete sources MAY use any encoding (base64 JSON, hex, etc.) so
|
||||
* long as `encodeCursor` and `decodeCursor` round-trip. The helpers
|
||||
* here implement the canonical base64url-JSON encoding used by the
|
||||
* in-memory reference impl (Stage 4); sources are free to ship their
|
||||
* own scheme.
|
||||
*/
|
||||
export interface CursorState {
|
||||
readonly offset: number;
|
||||
readonly version: 1;
|
||||
}
|
||||
|
||||
const cursorStateSchema = z
|
||||
.object({
|
||||
offset: z.number().int().nonnegative(),
|
||||
version: z.literal(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export function encodeCursor(state: CursorState): string {
|
||||
const json = JSON.stringify(state);
|
||||
return Buffer.from(json, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an opaque cursor string into its structured form.
|
||||
*
|
||||
* Throws {@link TenantProviderError} with code `INVALID_CURSOR` for
|
||||
* any input that is not a base64url-encoded JSON object matching
|
||||
* {@link CursorState}.
|
||||
*/
|
||||
export function decodeCursor(cursor: string): CursorState {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
||||
if (decoded.length === 0) {
|
||||
throw new TenantProviderError(
|
||||
"TENANT_PROVIDER.INVALID_CURSOR",
|
||||
`cursor ${cursor} is empty after decoding`
|
||||
);
|
||||
}
|
||||
parsed = JSON.parse(decoded);
|
||||
} catch (cause) {
|
||||
if (cause instanceof TenantProviderError) throw cause;
|
||||
throw new TenantProviderError(
|
||||
"TENANT_PROVIDER.INVALID_CURSOR",
|
||||
`cursor ${cursor} could not be decoded`,
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
const result = cursorStateSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new TenantProviderError(
|
||||
"TENANT_PROVIDER.INVALID_CURSOR",
|
||||
`cursor ${cursor} failed schema validation`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { TenantProvider } from "./provider.js";
|
||||
@@ -0,0 +1,125 @@
|
||||
# `TenantProvider` — abstract tenant-enumeration contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TenantProvider` is the abstract contract every concrete tenant source
|
||||
implements. The API plugin host consumes it for per-tenant cron fan-out
|
||||
(manifest `perTenant: true` jobs, design §5.4), admin tooling, per-tenant
|
||||
cache warmers, and background reconciliation. Putting the abstraction
|
||||
in its own package lets the host stay source-agnostic — the same plugin
|
||||
binary runs against an in-memory list in tests, against the host
|
||||
metadata DB in production, and against a directory service in
|
||||
enterprise deployments.
|
||||
|
||||
It is intentionally distinct from `TenantResolver` (which extracts the
|
||||
active tenant id from a single request). Resolvers answer "who is
|
||||
making this call right now"; providers answer "what is the universe of
|
||||
tenants this host knows about". Both contracts coexist in the
|
||||
api-stack but live in separate packages so consumers can depend on the
|
||||
narrower one they actually need.
|
||||
|
||||
## Features
|
||||
|
||||
- `list(opts?)` — `AsyncIterable<TenantRecord>` streamed one tenant at a time so large rosters don't force a full-set load.
|
||||
- `count()` — active-only count, intentionally separate from `list` so sources can shortcut to `COUNT(*) WHERE status='active'` when supported.
|
||||
- `lookup(id)` — by-id retrieval that returns `null` for unknown ids (never throws); the only way to reach `archived` tenants.
|
||||
- `dispose()` — idempotent cleanup hook for DB connections, directory-service sockets, background pollers.
|
||||
- No concrete implementation in this package — `api-tenant-provider-memory` (Stage 4) and DB-backed sources live in siblings.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
|
||||
|
||||
async function fanOutCron(provider: TenantProvider, run: (id: TenantId) => Promise<void>) {
|
||||
for await (const tenant of provider.list()) {
|
||||
await run(tenant.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
|
||||
import type { TenantRecord } from "@suaveplan/api-tenant-provider/tenant-record";
|
||||
import {
|
||||
TenantProviderError,
|
||||
TENANT_PROVIDER_CODES,
|
||||
} from "@suaveplan/api-tenant-provider/error-codes";
|
||||
|
||||
// Parallel cron fan-out with per-tenant timeout + structured error reporting.
|
||||
async function fanOutWithTimeout(
|
||||
provider: TenantProvider,
|
||||
run: (record: TenantRecord) => Promise<void>,
|
||||
options: { concurrency: number; perTenantTimeoutMs: number },
|
||||
): Promise<{ ok: number; failed: { id: string; reason: string }[] }> {
|
||||
const acc: TenantRecord[] = [];
|
||||
for await (const t of provider.list({ includeSuspended: false })) acc.push(t);
|
||||
|
||||
const failed: { id: string; reason: string }[] = [];
|
||||
let ok = 0;
|
||||
const queue = [...acc];
|
||||
const workers = Array.from({ length: options.concurrency }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const t = queue.shift();
|
||||
if (!t) return;
|
||||
try {
|
||||
await Promise.race([
|
||||
run(t),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), options.perTenantTimeoutMs),
|
||||
),
|
||||
]);
|
||||
ok += 1;
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof TenantProviderError &&
|
||||
cause.code === TENANT_PROVIDER_CODES.SOURCE_UNAVAILABLE
|
||||
) {
|
||||
// re-enqueue once for a single retry
|
||||
queue.push(t);
|
||||
continue;
|
||||
}
|
||||
failed.push({
|
||||
id: String(t.id),
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return { ok, failed };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TenantProvider`:
|
||||
- `list(opts?: ListOpts): AsyncIterable<TenantRecord>` — active-by-default; pass `{ includeSuspended: true }` to include suspended; archived never yielded.
|
||||
- `count(): Promise<number>` — active-only count.
|
||||
- `lookup(tenantId: TenantId): Promise<TenantRecord | null>` — never throws on missing; MAY throw `TENANT_PROVIDER.SOURCE_UNAVAILABLE` on infrastructure failure.
|
||||
- `dispose(): Promise<void>` — idempotent; calling more than once MUST NOT throw.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`list` returns `AsyncIterable<TenantRecord>` rather than
|
||||
`Promise<readonly TenantRecord[]>` so sources can stream pages from a
|
||||
DB cursor without materialising the full roster — important for
|
||||
deployments with thousands of tenants. Callers that DO want the full
|
||||
list one-shot can drain the iterable into an array; the iterable shape
|
||||
does not preclude that.
|
||||
|
||||
`count` is its own method, not `(await drain(list())).length`, because
|
||||
many sources can answer count in `O(1)` (a single `COUNT(*)` SQL row)
|
||||
where draining the iterable is `O(N)`. The contract leaves the choice
|
||||
to the implementor and lets callers pick the cheaper operation.
|
||||
|
||||
`dispose` MUST be idempotent because the host's lifecycle FSM (design
|
||||
§4) may call it during normal shutdown and again during forced
|
||||
shutdown — both calls MUST be safe even if the provider is already
|
||||
disposed.
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { ListOpts } from "../pagination/pagination.js";
|
||||
import type { TenantRecord } from "../tenant-record/tenant-record.js";
|
||||
import type { TenantProvider } from "./provider.js";
|
||||
|
||||
const tid = (s: string) => s as unknown as TenantId;
|
||||
|
||||
// Compile-time shape check — the `TenantProvider` interface declares
|
||||
// exactly `list`, `count`, `lookup`, `dispose`. This stub satisfies the
|
||||
// interface, which is the runtime evidence that the type compiles.
|
||||
class StubProvider implements TenantProvider {
|
||||
private readonly records: readonly TenantRecord[];
|
||||
public constructor(records: readonly TenantRecord[]) {
|
||||
this.records = records;
|
||||
}
|
||||
public async *list(opts?: ListOpts): AsyncIterable<TenantRecord> {
|
||||
const include = opts?.includeSuspended === true;
|
||||
for (const r of this.records) {
|
||||
if (
|
||||
r.status === "active" ||
|
||||
(include && r.status === "suspended")
|
||||
) {
|
||||
yield r;
|
||||
}
|
||||
}
|
||||
}
|
||||
public async count(): Promise<number> {
|
||||
return this.records.filter((r) => r.status === "active").length;
|
||||
}
|
||||
public async lookup(id: TenantId): Promise<TenantRecord | null> {
|
||||
return this.records.find((r) => r.id === id) ?? null;
|
||||
}
|
||||
public async dispose(): Promise<void> {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
describe("TenantProvider shape (via stub)", () => {
|
||||
it("declares list, count, lookup, dispose", { timeout: 5000 }, () => {
|
||||
const provider: TenantProvider = new StubProvider([]);
|
||||
expect(typeof provider.list).toBe("function");
|
||||
expect(typeof provider.count).toBe("function");
|
||||
expect(typeof provider.lookup).toBe("function");
|
||||
expect(typeof provider.dispose).toBe("function");
|
||||
});
|
||||
|
||||
it("lookup returns null for missing tenant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new StubProvider([]);
|
||||
expect(await provider.lookup(tid("nope"))).toBeNull();
|
||||
});
|
||||
|
||||
it("list iterates active tenants by default", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new StubProvider([
|
||||
{
|
||||
id: tid("a"),
|
||||
slug: "a",
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: tid("s"),
|
||||
slug: "s",
|
||||
status: "suspended",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
const seen: TenantRecord[] = [];
|
||||
for await (const r of provider.list()) seen.push(r);
|
||||
expect(seen.map((r) => r.id)).toEqual([tid("a")]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type { ListOpts } from "../pagination/pagination.js";
|
||||
import type { TenantRecord } from "../tenant-record/tenant-record.js";
|
||||
|
||||
/**
|
||||
* Abstract contract for enumerating the universe of tenants known to
|
||||
* an API host.
|
||||
*
|
||||
* Different deployments source this list from different places:
|
||||
* - the in-memory reference implementation (`api-tenant-provider-memory`,
|
||||
* Stage 4) for tests and simple single-tenant hosts;
|
||||
* - the host metadata database (Postgres / SurrealDB) for multi-tenant
|
||||
* SaaS deployments;
|
||||
* - a directory service (LDAP / SCIM) for enterprise deployments;
|
||||
* - a remote API for federated deployments.
|
||||
*
|
||||
* Per design doc §5.4, `perTenant: true` cron / queued jobs use this
|
||||
* contract to fan one job invocation out per active tenant. Admin
|
||||
* tooling, per-tenant cache warmers, and background reconciliation also
|
||||
* consume this contract.
|
||||
*
|
||||
* NOTE: this is intentionally distinct from `TenantResolver` (which
|
||||
* extracts the active tenant id from a request — that contract lives
|
||||
* in `@suaveplan/api-types/tenant-resolver`).
|
||||
*/
|
||||
export interface TenantProvider {
|
||||
/**
|
||||
* Iterate tenants in the host's inventory.
|
||||
*
|
||||
* Defaults to `status === "active"`. Pass `{ includeSuspended: true }`
|
||||
* to also yield `suspended` tenants. `archived` tenants are NEVER
|
||||
* yielded by `list` — they remain reachable via `lookup`.
|
||||
*
|
||||
* The returned iterable MAY be page-driven internally; callers
|
||||
* iterate one tenant at a time and need not know.
|
||||
*/
|
||||
list(opts?: ListOpts): AsyncIterable<TenantRecord>;
|
||||
|
||||
/**
|
||||
* Count tenants matching the same default filter as `list()`
|
||||
* (active-only).
|
||||
*
|
||||
* Sources MAY implement this as `O(1)` (a `COUNT(*) WHERE status='active'`
|
||||
* query) or fall back to iterating `list()` and counting.
|
||||
*/
|
||||
count(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Look up a single tenant by id.
|
||||
*
|
||||
* Returns `null` (never throws) when the id is unknown. Returns the
|
||||
* record regardless of `status` — `lookup` is the only way to reach
|
||||
* `archived` tenants. Sources MAY throw {@link
|
||||
* import("../error-codes/error-codes.js").TenantProviderError}
|
||||
* with code `SOURCE_UNAVAILABLE` for transient infrastructure
|
||||
* failures (DB down, directory service unreachable).
|
||||
*/
|
||||
lookup(tenantId: TenantId): Promise<TenantRecord | null>;
|
||||
|
||||
/**
|
||||
* Release any resources held by the provider (DB connections,
|
||||
* directory-service sockets, background pollers).
|
||||
*
|
||||
* MUST be idempotent — calling `dispose` more than once MUST NOT
|
||||
* throw. After `dispose` resolves, subsequent calls to `list`,
|
||||
* `count`, or `lookup` MAY fail.
|
||||
*/
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
TENANT_STATUSES,
|
||||
type TenantRecord,
|
||||
type TenantStatus,
|
||||
tenantRecordSchema,
|
||||
} from "./tenant-record.js";
|
||||
@@ -0,0 +1,95 @@
|
||||
# `TenantRecord` — the canonical tenant row
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A `TenantRecord` is one row from the host's tenant inventory. Every
|
||||
concrete `TenantProvider` — whether it reads from memory, a database,
|
||||
or a directory service — surfaces tenants as `TenantRecord` values, so
|
||||
downstream consumers (the cron scheduler, admin tooling, per-tenant
|
||||
cache warmers) can iterate the inventory without knowing how it is
|
||||
stored.
|
||||
|
||||
The Zod schema `tenantRecordSchema` lives next to the type so external
|
||||
sources (DB rows, JSON config, remote API responses) can be validated
|
||||
at the boundary. Strict mode is enabled so silent schema drift in the
|
||||
source — a column rename, an extra JSON property — surfaces as a Zod
|
||||
issue at parse time rather than as undefined behavior deep in the
|
||||
host.
|
||||
|
||||
## Features
|
||||
|
||||
- `id` is the branded `TenantId` from `@suaveplan/api-types/tenant` — plain `string` cannot be silently substituted.
|
||||
- `status` is the discriminated union `"active" | "suspended" | "archived"`; the runtime tuple `TENANT_STATUSES` exposes the same values.
|
||||
- `createdAt` is an ISO-8601 datetime string with required offset, parsed via Zod's `datetime({ offset: true })`.
|
||||
- `displayName` is optional and, when present, MUST be non-empty.
|
||||
- Strict-mode Zod — unknown keys produce a parse failure.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { tenantRecordSchema } from "@suaveplan/api-tenant-provider/tenant-record";
|
||||
|
||||
const record = tenantRecordSchema.parse({
|
||||
id: "t-42",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T12:00:00.000Z",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
TENANT_STATUSES,
|
||||
type TenantRecord,
|
||||
tenantRecordSchema,
|
||||
} from "@suaveplan/api-tenant-provider/tenant-record";
|
||||
|
||||
// Bulk-validate rows from a DB query, collecting per-row errors instead
|
||||
// of failing the entire batch.
|
||||
function parseRows(rows: readonly unknown[]): {
|
||||
ok: readonly TenantRecord[];
|
||||
failed: readonly { index: number; issues: string[] }[];
|
||||
} {
|
||||
const ok: TenantRecord[] = [];
|
||||
const failed: { index: number; issues: string[] }[] = [];
|
||||
rows.forEach((row, index) => {
|
||||
const result = tenantRecordSchema.safeParse(row);
|
||||
if (result.success) ok.push(result.data);
|
||||
else
|
||||
failed.push({
|
||||
index,
|
||||
issues: result.error.issues.map((i) => i.message),
|
||||
});
|
||||
});
|
||||
return { ok, failed };
|
||||
}
|
||||
|
||||
// `TENANT_STATUSES` is a tuple — use it for UI form options.
|
||||
const statusOptions = TENANT_STATUSES.map((s) => ({ value: s, label: s }));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TenantRecord { readonly id: TenantId; readonly displayName?: string; readonly status: TenantStatus; readonly createdAt: string }`.
|
||||
- `type TenantStatus = "active" | "suspended" | "archived"`.
|
||||
- `const TENANT_STATUSES = ["active", "suspended", "archived"] as const`.
|
||||
- `const tenantRecordSchema: z.ZodType<TenantRecord>` — strict-mode Zod schema; rejects unknown keys; `id` accepted as non-empty string and re-cast to `TenantId`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The brand on `TenantId` lives in `@suaveplan/api-types` and is enforced
|
||||
purely at the type level — there is no runtime tag — so the schema
|
||||
parses `id` as a plain `string().min(1)` and re-casts via a
|
||||
`transform`. Sources that need stronger runtime validation (UUID,
|
||||
slug-pattern, etc.) layer their own `refine` on top.
|
||||
|
||||
The `createdAt` field uses Zod's RFC-3339 datetime parser with `offset:
|
||||
true`, which means callers MUST include a timezone offset (`Z` or
|
||||
`+HH:MM`). This matches the wire format used everywhere else in the
|
||||
project (see `@suaveplan/datetime`) and avoids the ambiguous
|
||||
"interpret-as-local" failure mode that floating timestamps invite.
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TENANT_STATUSES, tenantRecordSchema } from "./tenant-record.js";
|
||||
|
||||
describe("tenantRecordSchema", () => {
|
||||
it("parses a minimal active record", { timeout: 5000 }, () => {
|
||||
const out = tenantRecordSchema.parse({
|
||||
id: "t1",
|
||||
slug: "t1",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T12:00:00.000Z",
|
||||
});
|
||||
expect(out.id).toBe("t1");
|
||||
expect(out.status).toBe("active");
|
||||
});
|
||||
|
||||
it("accepts a record with displayName", { timeout: 5000 }, () => {
|
||||
const out = tenantRecordSchema.parse({
|
||||
id: "t2",
|
||||
slug: "acme",
|
||||
displayName: "Acme",
|
||||
status: "suspended",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(out.displayName).toBe("Acme");
|
||||
expect(out.slug).toBe("acme");
|
||||
});
|
||||
|
||||
it("accepts a record with metadata", { timeout: 5000 }, () => {
|
||||
const out = tenantRecordSchema.parse({
|
||||
id: "t3",
|
||||
slug: "t3",
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: { tier: "gold", region: "us-east-1" },
|
||||
});
|
||||
expect(out.metadata?.["tier"]).toBe("gold");
|
||||
});
|
||||
|
||||
it("rejects record without slug", { timeout: 5000 }, () => {
|
||||
const result = tenantRecordSchema.safeParse({
|
||||
id: "t1",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty slug", { timeout: 5000 }, () => {
|
||||
const result = tenantRecordSchema.safeParse({
|
||||
id: "t1",
|
||||
slug: "",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown status", { timeout: 5000 }, () => {
|
||||
const result = tenantRecordSchema.safeParse({
|
||||
id: "t1",
|
||||
status: "weird",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) => i.path.join(".") === "status")
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict mode)", { timeout: 5000 }, () => {
|
||||
const result = tenantRecordSchema.safeParse({
|
||||
id: "t1",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
extra: "nope",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
const result = tenantRecordSchema.safeParse({
|
||||
id: "",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty displayName when present", { timeout: 5000 }, () => {
|
||||
const result = tenantRecordSchema.safeParse({
|
||||
id: "t1",
|
||||
displayName: "",
|
||||
status: "active",
|
||||
createdAt: "2026-05-13T00:00:00.000Z",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-ISO createdAt", { timeout: 5000 }, () => {
|
||||
const result = tenantRecordSchema.safeParse({
|
||||
id: "t1",
|
||||
status: "active",
|
||||
createdAt: "yesterday",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("TENANT_STATUSES tuple exposes all three values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect([...TENANT_STATUSES].sort()).toEqual([
|
||||
"active",
|
||||
"archived",
|
||||
"suspended",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* A row in the host's tenant inventory.
|
||||
*
|
||||
* Surfaced by every `TenantProvider`. `id` is the branded `TenantId`
|
||||
* from `@suaveplan/api-types/tenant` so a plain `string` cannot be
|
||||
* silently substituted. `slug` is the human-readable URL form (the same
|
||||
* shape used by `TenantContext.slug` in `@suaveplan/api-types/tenant`),
|
||||
* carried on every record so consumers can build a per-request
|
||||
* `TenantContext` without re-querying the inventory or synthesising the
|
||||
* slug from the id. `status` is `"active"` for tenants currently eligible
|
||||
* for per-tenant fan-out, `"suspended"` for tenants that are paused
|
||||
* (visible only when `list({ includeSuspended: true })`), and
|
||||
* `"archived"` for tenants that are never enumerated by `list` (they
|
||||
* remain reachable via `lookup`). `metadata` is the same adapter-extensible
|
||||
* bag exposed on `TenantContext.metadata`.
|
||||
*/
|
||||
export interface TenantRecord {
|
||||
readonly id: TenantId;
|
||||
readonly slug: string;
|
||||
readonly displayName?: string;
|
||||
readonly status: TenantStatus;
|
||||
readonly createdAt: string;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type TenantStatus = "active" | "suspended" | "archived";
|
||||
|
||||
export const TENANT_STATUSES = ["active", "suspended", "archived"] as const;
|
||||
|
||||
/**
|
||||
* Zod schema for `TenantRecord`.
|
||||
*
|
||||
* Validates inputs from external sources (DB rows, API responses,
|
||||
* configuration). Strict mode — unknown keys are rejected so silent
|
||||
* schema drift in upstream sources surfaces at the boundary, not deep
|
||||
* in the host. `id` is parsed as a non-empty string and cast to
|
||||
* `TenantId`; `createdAt` is parsed as an ISO-8601 datetime string.
|
||||
*/
|
||||
export const tenantRecordSchema: z.ZodType<TenantRecord> = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((s) => s as unknown as TenantId),
|
||||
slug: z.string().min(1),
|
||||
displayName: z.string().min(1).optional(),
|
||||
status: z.enum(TENANT_STATUSES),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict() as unknown as z.ZodType<TenantRecord>;
|
||||
@@ -0,0 +1,88 @@
|
||||
# `runTenantProviderConformance` — reusable conformance harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every concrete `TenantProvider` — the in-memory reference impl
|
||||
(`api-tenant-provider-memory`, ), the host-DB-backed adapter,
|
||||
any directory-service adapter — MUST pass the same behavioural suite.
|
||||
Putting the suite here, importable as `runTenantProviderConformance`,
|
||||
guarantees that adding a new provider source is a one-line job in the
|
||||
adapter's test file and that every adapter agrees on the contract's
|
||||
edge cases (active-only default, includeSuspended, lookup-returns-null,
|
||||
cursor invalidation, dispose idempotency).
|
||||
|
||||
The harness uses `describe` / `it` / `expect` from
|
||||
`@suaveplan/testing/runner` so it works under both the Vitest default
|
||||
runner and the Bun-conditional runner (rule 11).
|
||||
|
||||
## Features
|
||||
|
||||
- Eight tests covering `list` default filter, `includeSuspended`, `count`, `lookup`-returns-record, `lookup`-returns-null, pagination round-trip, invalid-cursor, and dispose idempotency.
|
||||
- `factory(seed)` callback constructs a fresh provider per test — no shared singleton, no leaked state.
|
||||
- Compatible with both Vitest and Bun via `@suaveplan/testing/runner`.
|
||||
- Every `it(...)` carries `{ timeout: 5000 }` per CLAUDE.md.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// In api-tenant-provider-memory/src/memory.test.ts (Stage 4):
|
||||
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
|
||||
import { MemoryTenantProvider } from "./memory.js";
|
||||
|
||||
runTenantProviderConformance({
|
||||
label: "in-memory",
|
||||
factory: (seed) => new MemoryTenantProvider(seed),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
|
||||
import { SurrealTenantProvider } from "./surreal.js";
|
||||
import { startSurrealdb } from "@suaveplan/testing-integration-surrealdb";
|
||||
|
||||
// Spin up a real SurrealDB container per provider instance — the
|
||||
// factory can be async, so heavyweight fixtures are first-class.
|
||||
runTenantProviderConformance({
|
||||
label: "surrealdb",
|
||||
factory: async (seed) => {
|
||||
const db = await startSurrealdb();
|
||||
const provider = new SurrealTenantProvider(db);
|
||||
for (const record of seed) await provider.put(record);
|
||||
// Wrap dispose so the container also stops when the test ends.
|
||||
const originalDispose = provider.dispose.bind(provider);
|
||||
provider.dispose = async () => {
|
||||
await originalDispose();
|
||||
await db.stop();
|
||||
};
|
||||
return provider;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TenantProviderConformanceOptions { readonly label: string; readonly factory: (seed: readonly TenantRecord[]) => Promise<TenantProvider> | TenantProvider }`.
|
||||
- `function runTenantProviderConformance(options: TenantProviderConformanceOptions): void` — registers a `describe` block with the documented tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness is intentionally minimal: it does not test telemetry,
|
||||
metrics, or implementation-specific properties — those belong in the
|
||||
adapter's own test file. It only pins the cross-adapter contract that
|
||||
every consumer of `TenantProvider` relies on.
|
||||
|
||||
The `factory` callback receives a `readonly TenantRecord[]` seed and
|
||||
MUST return a provider in which `list({ includeSuspended: true })`
|
||||
yields exactly the active and suspended subset of `seed` (deterministic
|
||||
order is not required — assertions normalise via sort).
|
||||
|
||||
`dispose` idempotency is tested by calling `dispose()` twice and
|
||||
asserting the second call resolves. Adapters whose `dispose` reasonably
|
||||
fails on double-call must reset internal state on the first call to
|
||||
make the second a no-op.
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import {
|
||||
TENANT_PROVIDER_CODES,
|
||||
TenantProviderError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { decodeCursor, type ListOpts } from "../pagination/pagination.js";
|
||||
import type { TenantProvider } from "../provider/provider.js";
|
||||
import type { TenantRecord } from "../tenant-record/tenant-record.js";
|
||||
import { runTenantProviderConformance } from "./conformance.js";
|
||||
|
||||
// Minimal stub provider that satisfies the conformance harness — used
|
||||
// here to drive coverage of every harness branch.
|
||||
class ConformantStub implements TenantProvider {
|
||||
private records: readonly TenantRecord[];
|
||||
private disposed = false;
|
||||
public constructor(seed: readonly TenantRecord[]) {
|
||||
this.records = seed;
|
||||
}
|
||||
public async *list(opts?: ListOpts): AsyncIterable<TenantRecord> {
|
||||
if (opts?.cursor !== undefined) {
|
||||
// Eagerly validate the cursor so an invalid one throws even
|
||||
// when no records would have been yielded.
|
||||
decodeCursor(opts.cursor);
|
||||
}
|
||||
const include = opts?.includeSuspended === true;
|
||||
for (const r of this.records) {
|
||||
if (
|
||||
r.status === "active" ||
|
||||
(include && r.status === "suspended")
|
||||
) {
|
||||
yield r;
|
||||
}
|
||||
}
|
||||
}
|
||||
public async count(): Promise<number> {
|
||||
return this.records.filter((r) => r.status === "active").length;
|
||||
}
|
||||
public async lookup(id: TenantId): Promise<TenantRecord | null> {
|
||||
return this.records.find((r) => r.id === id) ?? null;
|
||||
}
|
||||
public async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
void this.disposed;
|
||||
}
|
||||
}
|
||||
|
||||
runTenantProviderConformance({
|
||||
label: "stub",
|
||||
factory: (seed) => new ConformantStub(seed),
|
||||
});
|
||||
|
||||
// A separate sanity test that simply asserts the harness export type
|
||||
// matches the documented contract and that TENANT_PROVIDER_CODES is
|
||||
// reachable from here (smoke).
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("harness sanity", () => {
|
||||
it("INVALID_CURSOR is the code thrown by an invalid cursor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new ConformantStub([]);
|
||||
let captured: unknown;
|
||||
try {
|
||||
for await (const _ of provider.list({ cursor: "x" })) void _;
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(TenantProviderError);
|
||||
if (captured instanceof TenantProviderError) {
|
||||
expect(captured.code).toBe(TENANT_PROVIDER_CODES.INVALID_CURSOR);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TenantProviderError } from "../error-codes/error-codes.js";
|
||||
import type { TenantProvider } from "../provider/provider.js";
|
||||
import type { TenantRecord } from "../tenant-record/tenant-record.js";
|
||||
|
||||
/**
|
||||
* Options handed to {@link runTenantProviderConformance}.
|
||||
*
|
||||
* `factory` MUST return a fresh `TenantProvider` seeded with the given
|
||||
* `seed` records — exactly those records, no extras. The harness uses
|
||||
* `factory` to construct independent provider instances per test so
|
||||
* suites cannot leak state through a shared singleton.
|
||||
*/
|
||||
export interface TenantProviderConformanceOptions {
|
||||
readonly label: string;
|
||||
readonly factory: (
|
||||
seed: readonly TenantRecord[]
|
||||
) => Promise<TenantProvider> | TenantProvider;
|
||||
}
|
||||
|
||||
const tid = (s: string) => s as unknown as TenantId;
|
||||
|
||||
function record(
|
||||
id: string,
|
||||
status: TenantRecord["status"],
|
||||
createdAt = "2026-01-01T00:00:00.000Z"
|
||||
): TenantRecord {
|
||||
return { id: tid(id), slug: id, status, createdAt };
|
||||
}
|
||||
|
||||
async function drain(
|
||||
iter: AsyncIterable<TenantRecord>
|
||||
): Promise<TenantRecord[]> {
|
||||
const acc: TenantRecord[] = [];
|
||||
for await (const r of iter) acc.push(r);
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable conformance harness. Every concrete `TenantProvider`
|
||||
* (in-memory in Stage 4, DB-backed downstream) MUST pass this suite
|
||||
* by calling it from a `.test.ts` of its own.
|
||||
*/
|
||||
export function runTenantProviderConformance(
|
||||
options: TenantProviderConformanceOptions
|
||||
): void {
|
||||
const { label, factory } = options;
|
||||
|
||||
describe(`TenantProvider conformance — ${label}`, () => {
|
||||
it("list yields only active tenants by default", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const seed = [
|
||||
record("a", "active"),
|
||||
record("s", "suspended"),
|
||||
record("z", "archived"),
|
||||
];
|
||||
const provider = await factory(seed);
|
||||
const rows = await drain(provider.list());
|
||||
expect(rows.map((r) => r.id)).toEqual([tid("a")]);
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("list({ includeSuspended }) surfaces suspended", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const seed = [
|
||||
record("a", "active"),
|
||||
record("s", "suspended"),
|
||||
record("z", "archived"),
|
||||
];
|
||||
const provider = await factory(seed);
|
||||
const ids = (await drain(provider.list({ includeSuspended: true })))
|
||||
.map((r) => r.id)
|
||||
.sort();
|
||||
expect(ids).toEqual([tid("a"), tid("s")]);
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("count returns active-only total", { timeout: 5000 }, async () => {
|
||||
const seed = [
|
||||
record("a", "active"),
|
||||
record("b", "active"),
|
||||
record("s", "suspended"),
|
||||
];
|
||||
const provider = await factory(seed);
|
||||
expect(await provider.count()).toBe(2);
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("lookup returns archived records", { timeout: 5000 }, async () => {
|
||||
const seed = [record("z", "archived")];
|
||||
const provider = await factory(seed);
|
||||
const got = await provider.lookup(tid("z"));
|
||||
expect(got?.id).toBe(tid("z"));
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("lookup returns null for missing tenant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = await factory([]);
|
||||
expect(await provider.lookup(tid("nope"))).toBeNull();
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("pagination cursor round-trips without gap or overlap", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const seed = Array.from({ length: 25 }, (_, i) =>
|
||||
record(`t${String(i).padStart(2, "0")}`, "active")
|
||||
);
|
||||
const provider = await factory(seed);
|
||||
const all = (await drain(provider.list())).map((r) => r.id);
|
||||
expect(all).toHaveLength(25);
|
||||
expect(new Set(all).size).toBe(25);
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("invalid cursor throws TENANT_PROVIDER.INVALID_CURSOR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = await factory([record("a", "active")]);
|
||||
await expect(
|
||||
drain(provider.list({ cursor: "not-a-cursor" }))
|
||||
).rejects.toBeInstanceOf(TenantProviderError);
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const provider = await factory([]);
|
||||
await provider.dispose();
|
||||
await expect(provider.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
runTenantProviderConformance,
|
||||
type TenantProviderConformanceOptions,
|
||||
} from "./conformance.js";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgJson = JSON.parse(
|
||||
readFileSync(resolve(here, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("api-tenant-provider dependency graph", () => {
|
||||
it("declares the expected runtime deps", { timeout: 5000 }, () => {
|
||||
const deps = Object.keys(pkgJson.dependencies).sort();
|
||||
expect(deps).toEqual([
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses workspace:^ for every @suaveplan/* runtime dep", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const [name, range] of Object.entries(pkgJson.dependencies)) {
|
||||
if (name.startsWith("@suaveplan/")) {
|
||||
expect(range).toBe("workspace:^");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT depend on @suaveplan/api-host or @suaveplan/api-router", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Stage-1 contract package — must not see the host or router.
|
||||
expect(pkgJson.dependencies["@suaveplan/api-host"]).toBeUndefined();
|
||||
expect(pkgJson.dependencies["@suaveplan/api-router"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const srcRoot = resolve(here, "..", "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walk(full));
|
||||
else if (full.endsWith(".ts")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("zod import discipline", () => {
|
||||
it("every src file uses default-import z (never named { z })", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of walk(srcRoot)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
// Named-form: import { z } from "zod"
|
||||
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(text)) {
|
||||
offenders.push(file);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-tenant-provider/tsconfig.json
Normal file
12
packages/api-stack/api/api-tenant-provider/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-tenant-provider/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-tenant-provider/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-tenant-provider/vite.config.ts
Normal file
30
packages/api-stack/api/api-tenant-provider/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-tenant-provider",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-tenant-provider/vitest.config.ts
Normal file
31
packages/api-stack/api/api-tenant-provider/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user