Initial Commit

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

View File

@@ -0,0 +1,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.

View 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"]
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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";

View File

@@ -0,0 +1,5 @@
export {
type CapabilityId,
TENANT_PROVIDER_CAPABILITY_ID,
type TenantProviderCapabilityId,
} from "./capability-id.js";

View 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.

View File

@@ -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",
]);
});
});

View 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;
},
};
}

View File

@@ -0,0 +1,7 @@
export {
type ChainCacheOptions,
type JwtClaimExtractor,
recordToContext,
type TenantResolverChainFromProviderOptions,
tenantResolverChainFromProvider,
} from "./chain.js";

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
export {
TENANT_PROVIDER_CODES,
TenantProviderError,
type TenantProviderErrorCode,
} from "./error-codes.js";

View 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";

View File

@@ -0,0 +1,7 @@
export {
type CursorState,
decodeCursor,
encodeCursor,
type ListOpts,
listOptsSchema,
} from "./pagination.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export type { TenantProvider } from "./provider.js";

View File

@@ -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.

View File

@@ -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")]);
});
});

View File

@@ -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>;
}

View File

@@ -0,0 +1,6 @@
export {
TENANT_STATUSES,
type TenantRecord,
type TenantStatus,
tenantRecordSchema,
} from "./tenant-record.js";

View File

@@ -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.

View File

@@ -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",
]);
});
});

View File

@@ -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>;

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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();
});
});
}

View File

@@ -0,0 +1,4 @@
export {
runTenantProviderConformance,
type TenantProviderConformanceOptions,
} from "./conformance.js";

View File

@@ -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();
});
});

View File

@@ -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([]);
});
});

View 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"]
}

View 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"]
}

View 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"],
},
},
});

View 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,
},
});