Initial Commit
This commit is contained in:
102
packages/api-stack/api/api-tenant-resolver-header/README.md
Normal file
102
packages/api-stack/api/api-tenant-resolver-header/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @suaveplan/api-tenant-resolver-header — header-based tenant resolver for the Suaveplan API plugin system. Implements the TenantResolver contract from @suaveplan/api-types by reading the tenant identifier from a configurable HTTP header (default X-Tenant-Id), validating against a regex (default ^[a-z0-9-]{1,64}$) and an optional registry (a `lookup`-shaped TenantLookup compatible with @suaveplan/api-tenant-provider), and enforcing trusted-source metadata so end-user clients cannot spoof tenant headers. Strict mode raises namespaced TENANT_HEADER_INVALID / NOT_FOUND_IN_REGISTRY / UNTRUSTED_SOURCE errors; non-strict mode returns null so the resolver chain advances. Emits api.tenant.resolves_total{resolver="header"} counters via the OTel global meter.
|
||||
|
||||
Stage-8 tenant resolver. Implements the `TenantResolver` contract from
|
||||
`@suaveplan/api-types/tenant-resolver` by reading the tenant identifier from
|
||||
a configurable HTTP header (default `X-Tenant-Id`), validating its shape
|
||||
against a regex (default `^[a-z0-9-]{1,64}$`), optionally probing a
|
||||
registry, and enforcing trusted-source metadata so end-user clients cannot
|
||||
spoof the tenant.
|
||||
|
||||
```ts
|
||||
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header";
|
||||
|
||||
const resolver = createHeaderTenantResolver();
|
||||
// service-mesh middleware stamps request.metadata.source = "service-mesh"
|
||||
const ctx = await resolver.resolve(request);
|
||||
// → { id: "acme", slug: "acme" } | null
|
||||
```
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-tenant-resolver-header`. Every public failure path raises a…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-tenant-resolver-header`. Every public failure path raises a…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument bundle the header tenant resolver emits on every `resolve()` invocation. Centralising instru…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument bundle the header tenant resolver emits on every `resolve()` invocation. Centralising instru…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated options schema accepted by `createHeaderTenantResolver`. Construction-time validation guarant…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated options schema accepted by `createHeaderTenantResolver`. Construction-time validation guarant…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements the Stage-8 tenant resolver factory `createHeaderTenantResolver`. The resolver reads the tenant identifier f…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements the Stage-8 tenant resolver factory `createHeaderTenantResolver`. The resolver reads the tenant identifier f…
|
||||
- [trust](./src/trust/trust.md) — Encapsulates the trusted-source enforcement consulted by the header tenant resolver before it acts on a header value. T…
|
||||
- [trust](./src/trust/trust.md) — Encapsulates the trusted-source enforcement consulted by the header tenant resolver before it acts on a header value. T…
|
||||
- [validate](./src/validate/validate.md) — Owns the default tenant-id validator regex and the validator / registry function signatures consumed by `createHeaderTe…
|
||||
- [validate](./src/validate/validate.md) — Owns the default tenant-id validator regex and the validator / registry function signatures consumed by `createHeaderTe…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why a dedicated package
|
||||
|
||||
The tenant-resolver chain (design doc §6 step 4) runs after the session
|
||||
resolver. Service-to-service traffic frequently carries the tenant in an
|
||||
HTTP header (`X-Tenant-Id`, `X-Acme-Tenant`, …) because no end-user session
|
||||
exists to resolve it from. This adapter handles that case while preventing
|
||||
header spoofing from public traffic via the trusted-source gate.
|
||||
|
||||
## Chain interaction
|
||||
|
||||
The host runs resolvers left-to-right; the first non-`null` result wins:
|
||||
|
||||
```text
|
||||
[ api-tenant-resolver-jwt, api-tenant-resolver-header, api-tenant-resolver-subdomain ]
|
||||
| | |
|
||||
reads principal reads X-Tenant-Id reads sub.domain
|
||||
(typical bearer) (admin / S2S calls) (public site routes)
|
||||
```
|
||||
|
||||
`api-tenant-resolver-header` returns `null` when the header is absent, when
|
||||
the request source is untrusted (non-strict), when the value fails
|
||||
validation (non-strict), or when the value isn't in the registry
|
||||
(non-strict). In strict mode each of those paths raises the corresponding
|
||||
`TENANT_HEADER_*` code.
|
||||
|
||||
## Modules
|
||||
|
||||
- `./resolver` — `createHeaderTenantResolver`,
|
||||
`CreateHeaderTenantResolverOptions`.
|
||||
- `./options` — `HeaderTenantResolverOptions`,
|
||||
`parseHeaderTenantResolverOptions`, `ResolvedHeaderTenantResolverOptions`.
|
||||
- `./validate` — `defaultTenantIdValidator`, `DEFAULT_TENANT_ID_PATTERN`,
|
||||
`TenantIdValidator`, `TenantRegistryProbe`.
|
||||
- `./trust` — `defaultMetadataExtractor`, `isTrustedSource`,
|
||||
`RequestMetadataExtractor`, `RequestTrustMetadata`.
|
||||
- `./instruments` — `createHeaderResolverInstruments`,
|
||||
`HeaderResolverInstruments`, `HeaderResolveOutcome`.
|
||||
- `./error-codes` — `HeaderTenantResolverError`,
|
||||
`HeaderTenantResolverErrorCode`, `TENANT_HEADER_CODES`.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Default | Effect |
|
||||
| ---------------- | -------------------- | ---------------------------------------------------------------------------- |
|
||||
| `id` | `"header"` | Resolver id used in chain logs and metric tags. |
|
||||
| `headerName` | `"X-Tenant-Id"` | HTTP header consulted; case-insensitive. |
|
||||
| `strict` | `false` | When `true`, failures raise typed errors. When `false`, failures yield `null`. |
|
||||
| `trustedSources` | `["service-mesh"]` | Values for `request.metadata.source` that may set the tenant header. |
|
||||
| `validate` | `^[a-z0-9-]{1,64}$` | Validator applied to the header value. |
|
||||
| `registry` | `undefined` | Optional sync-or-async membership probe; runs only after validator passes. |
|
||||
| `getMetadata` | reads `req.metadata` | Override for the trust-metadata extractor. |
|
||||
| `instruments` | OTel global meter | Override for the instrument bundle. |
|
||||
|
||||
## Error codes
|
||||
|
||||
- `TENANT_HEADER.INVALID` — header value failed validator (strict-only).
|
||||
- `TENANT_HEADER.NOT_FOUND_IN_REGISTRY` — value not in registry (strict-only).
|
||||
- `TENANT_HEADER.UNTRUSTED_SOURCE` — request source not in `trustedSources`
|
||||
(strict-only).
|
||||
- `TENANT_HEADER.INVALID_OPTIONS` — construction-time Zod failure.
|
||||
|
||||
## Observability
|
||||
|
||||
- Counter `api.tenant.resolves_total{resolver="header", outcome}` — exactly
|
||||
one increment per `resolve()` call. `outcome` is one of `resolved`,
|
||||
`absent`, `invalid`, `not_found_in_registry`, `untrusted_source`.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "@suaveplan/api-tenant-resolver-header",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-8 header-based tenant resolver for the Suaveplan API plugin foundation. Implements the TenantResolver contract from @suaveplan/api-types by reading the tenant identifier from a configurable HTTP header (default X-Tenant-Id), validating against a regex (default ^[a-z0-9-]{1,64}$) and an optional registry (a `lookup`-shaped TenantLookup compatible with @suaveplan/api-tenant-provider), and enforcing trusted-source metadata so end-user clients cannot spoof tenant headers. Strict mode raises namespaced TENANT_HEADER_INVALID / NOT_FOUND_IN_REGISTRY / UNTRUSTED_SOURCE errors; non-strict mode returns null so the resolver chain advances. Emits api.tenant.resolves_total{resolver=\"header\"} counters via the OTel global meter.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./resolver": {
|
||||
"import": {
|
||||
"types": "./dist/resolver/resolver.d.ts",
|
||||
"default": "./dist/resolver/resolver.js"
|
||||
}
|
||||
},
|
||||
"./options": {
|
||||
"import": {
|
||||
"types": "./dist/options/options.d.ts",
|
||||
"default": "./dist/options/options.js"
|
||||
}
|
||||
},
|
||||
"./validate": {
|
||||
"import": {
|
||||
"types": "./dist/validate/validate.d.ts",
|
||||
"default": "./dist/validate/validate.js"
|
||||
}
|
||||
},
|
||||
"./trust": {
|
||||
"import": {
|
||||
"types": "./dist/trust/trust.d.ts",
|
||||
"default": "./dist/trust/trust.js"
|
||||
}
|
||||
},
|
||||
"./instruments": {
|
||||
"import": {
|
||||
"types": "./dist/instruments/instruments.d.ts",
|
||||
"default": "./dist/instruments/instruments.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "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,86 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry for
|
||||
`@suaveplan/api-tenant-resolver-header`. Every public failure path raises a
|
||||
{@link HeaderTenantResolverError} carrying one of the namespaced
|
||||
`TENANT_HEADER.*` codes so consumers branch on a string value rather than a
|
||||
brittle `instanceof` check on a third-party concrete error class.
|
||||
|
||||
The registry is the single source of truth for the resolver's wire-level
|
||||
failure modes. Strict-mode hosts map the codes to HTTP responses (`INVALID`,
|
||||
`NOT_FOUND_IN_REGISTRY`, `UNTRUSTED_SOURCE` all surface as HTTP 400 unless
|
||||
the host overrides the mapping); non-strict-mode hosts swallow the codes by
|
||||
returning `null` from the resolver so the chain advances.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen four-entry registry: `INVALID`, `NOT_FOUND_IN_REGISTRY`,
|
||||
`UNTRUSTED_SOURCE`, `INVALID_OPTIONS`.
|
||||
- Namespaced `TENANT_HEADER.*` codes built via `defineCodes` from
|
||||
`@suaveplan/error`.
|
||||
- `HeaderTenantResolverError` subclass narrows `code` for downstream
|
||||
pattern-matching and carries a `headerTenantResolverCode` mirror field for
|
||||
ergonomic switch statements.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-header/error-codes";
|
||||
|
||||
throw new HeaderTenantResolverError(
|
||||
TENANT_HEADER_CODES.INVALID,
|
||||
"X-Tenant-Id 'Acme!' does not match ^[a-z0-9-]{1,64}$"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-header/error-codes";
|
||||
|
||||
function toHttpStatus(error: HeaderTenantResolverError): number {
|
||||
switch (error.code) {
|
||||
case TENANT_HEADER_CODES.INVALID:
|
||||
case TENANT_HEADER_CODES.NOT_FOUND_IN_REGISTRY:
|
||||
case TENANT_HEADER_CODES.UNTRUSTED_SOURCE:
|
||||
return 400;
|
||||
case TENANT_HEADER_CODES.INVALID_OPTIONS:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_HEADER_CODES` — frozen registry with keys `INVALID`,
|
||||
`NOT_FOUND_IN_REGISTRY`, `UNTRUSTED_SOURCE`, `INVALID_OPTIONS`.
|
||||
- `type HeaderTenantResolverErrorCode` — string union of the four code
|
||||
values, e.g. `"TENANT_HEADER.INVALID"`.
|
||||
- `class HeaderTenantResolverError extends SuaveplanError<HeaderTenantResolverErrorCode>`
|
||||
— accepts `(code, message, { cause? })` and exposes
|
||||
`headerTenantResolverCode` alongside the inherited `code` field.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally minimal — four codes covering the resolver's
|
||||
four distinct outcomes. Adding a code requires a spec delta because the codes
|
||||
are part of the package's wire contract. Hosts that need finer-grained
|
||||
diagnostics attach detail via `error.cause`; the codes themselves stay coarse
|
||||
so consumers' switch statements don't proliferate when new validators are
|
||||
plugged in.
|
||||
|
||||
`defineCodes` freezes the returned object so accidental mutation of the
|
||||
registry at runtime is impossible — the four `TENANT_HEADER.*` strings are
|
||||
the only values that will ever flow through the public surface.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("TENANT_HEADER_CODES registry", () => {
|
||||
it("exposes exactly the four documented codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(TENANT_HEADER_CODES).sort()).toEqual([
|
||||
"INVALID",
|
||||
"INVALID_OPTIONS",
|
||||
"NOT_FOUND_IN_REGISTRY",
|
||||
"UNTRUSTED_SOURCE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("namespaces every code under TENANT_HEADER.*", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(TENANT_HEADER_CODES)) {
|
||||
expect(value.startsWith("TENANT_HEADER.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen at the top level", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(TENANT_HEADER_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeaderTenantResolverError", () => {
|
||||
it("narrows code to HeaderTenantResolverErrorCode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new HeaderTenantResolverError(
|
||||
TENANT_HEADER_CODES.INVALID,
|
||||
"boom"
|
||||
);
|
||||
expect(err.code).toBe("TENANT_HEADER.INVALID");
|
||||
expect(err.headerTenantResolverCode).toBe(TENANT_HEADER_CODES.INVALID);
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("forwards cause via options", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new HeaderTenantResolverError(
|
||||
TENANT_HEADER_CODES.INVALID_OPTIONS,
|
||||
"bad",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-tenant-resolver-header`.
|
||||
*
|
||||
* Every public failure path raises a {@link HeaderTenantResolverError}
|
||||
* carrying one of the namespaced `TENANT_HEADER.*` codes below. Consumers
|
||||
* branch on the string value
|
||||
* (e.g. `error.code === TENANT_HEADER_CODES.INVALID`).
|
||||
*
|
||||
* Codes:
|
||||
* - `INVALID` — header is present but fails the configured validator.
|
||||
* Strict-mode surfaces this as HTTP 400; non-strict-mode returns `null`.
|
||||
* - `NOT_FOUND_IN_REGISTRY` — header value passes the validator but is not
|
||||
* a member of the optional tenant registry. Strict-mode surfaces this as
|
||||
* HTTP 400; non-strict-mode returns `null`.
|
||||
* - `UNTRUSTED_SOURCE` — header is present but the request's metadata
|
||||
* `source` is not in the configured `trustedSources` list. Strict-mode
|
||||
* raises; non-strict-mode returns `null`.
|
||||
* - `INVALID_OPTIONS` — `createHeaderTenantResolver` called with options
|
||||
* that fail Zod validation at construction time.
|
||||
*/
|
||||
export const TENANT_HEADER_CODES = defineCodes("TENANT_HEADER", [
|
||||
"INVALID",
|
||||
"NOT_FOUND_IN_REGISTRY",
|
||||
"UNTRUSTED_SOURCE",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type HeaderTenantResolverErrorCode =
|
||||
(typeof TENANT_HEADER_CODES)[keyof typeof TENANT_HEADER_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for `@suaveplan/api-tenant-resolver-header`. Extends
|
||||
* {@link SuaveplanError} so `instanceof HeaderTenantResolverError` narrows
|
||||
* `code` to {@link HeaderTenantResolverErrorCode}.
|
||||
*/
|
||||
export class HeaderTenantResolverError extends SuaveplanError<HeaderTenantResolverErrorCode> {
|
||||
public readonly headerTenantResolverCode: HeaderTenantResolverErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: HeaderTenantResolverErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.headerTenantResolverCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
HeaderTenantResolverError,
|
||||
type HeaderTenantResolverErrorCode,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,31 @@
|
||||
export {
|
||||
HeaderTenantResolverError,
|
||||
type HeaderTenantResolverErrorCode,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
createHeaderResolverInstruments,
|
||||
type HeaderResolveOutcome,
|
||||
type HeaderResolverInstruments,
|
||||
} from "./instruments/index.js";
|
||||
export {
|
||||
type HeaderTenantResolverOptions,
|
||||
parseHeaderTenantResolverOptions,
|
||||
type ResolvedHeaderTenantResolverOptions,
|
||||
} from "./options/index.js";
|
||||
export {
|
||||
type CreateHeaderTenantResolverOptions,
|
||||
createHeaderTenantResolver,
|
||||
} from "./resolver/index.js";
|
||||
export {
|
||||
defaultMetadataExtractor,
|
||||
isTrustedSource,
|
||||
type RequestMetadataExtractor,
|
||||
type RequestTrustMetadata,
|
||||
} from "./trust/index.js";
|
||||
export {
|
||||
DEFAULT_TENANT_ID_PATTERN,
|
||||
defaultTenantIdValidator,
|
||||
type TenantIdValidator,
|
||||
type TenantRegistryProbe,
|
||||
} from "./validate/index.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createHeaderResolverInstruments,
|
||||
type HeaderResolveOutcome,
|
||||
type HeaderResolverInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
# `instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the OTel instrument bundle the header tenant resolver emits on every
|
||||
`resolve()` invocation. Centralising instrument creation keeps the resolver
|
||||
factory free of meter plumbing — the resolver simply records against the
|
||||
frozen counter returned here. The counter name (`api.tenant.resolves_total`)
|
||||
follows the API plugin system design doc §6 step 4 so a missing entry
|
||||
is a spec deviation a CI gate could detect.
|
||||
|
||||
The instrument is wired against the OTel global meter provider by default,
|
||||
which falls back to a no-op implementation when no SDK is installed. Tests
|
||||
need not configure a provider; production hosts register their SDK once at
|
||||
boot and the resolver picks it up automatically.
|
||||
|
||||
## Features
|
||||
|
||||
- One counter: `api.tenant.resolves_total` labelled
|
||||
`{ resolver: "header", outcome }`.
|
||||
- `outcome` attribute drawn from a closed five-value union
|
||||
(`resolved`, `absent`, `invalid`, `not_found_in_registry`,
|
||||
`untrusted_source`) so dashboards can break down resolver behaviour.
|
||||
- Optional `meterProvider` injection for tests that want a captive
|
||||
provider; defaults to the OTel global.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createHeaderResolverInstruments } from "@suaveplan/api-tenant-resolver-header/instruments";
|
||||
|
||||
const instruments = createHeaderResolverInstruments();
|
||||
instruments.resolvesTotal.add(1, { resolver: "header", outcome: "resolved" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { createHeaderResolverInstruments } from "@suaveplan/api-tenant-resolver-header/instruments";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
const instruments = createHeaderResolverInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
// hook instruments.meter into a Prometheus exporter to scrape
|
||||
// `api_tenant_resolves_total{resolver="header",outcome="invalid"}` etc.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HeaderResolverInstruments` — `{ meter, resolvesTotal }`.
|
||||
- `type HeaderResolveOutcome` — five-value union of outcome attribute
|
||||
values.
|
||||
- `function createHeaderResolverInstruments(config?: { meterProvider? })`
|
||||
— returns a frozen instrument bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bundle is frozen with `Object.freeze` so consumers cannot accidentally
|
||||
swap the counter instance at runtime. The counter is created lazily-per-
|
||||
resolver-instance rather than module-level so a host that wants different
|
||||
meter providers per host instance (multi-tenant test harness) can wire each
|
||||
resolver separately.
|
||||
|
||||
A counter — not a histogram — is the right primitive here because the
|
||||
quantity being observed is the count of resolver invocations broken down by
|
||||
outcome, not a duration. Resolver latency is dominated by the registry
|
||||
probe; hosts that need probe latency wire a histogram around their
|
||||
`TenantRegistryProbe` rather than expecting this package to time it.
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createHeaderResolverInstruments } from "./instruments.js";
|
||||
|
||||
describe("createHeaderResolverInstruments", () => {
|
||||
it("returns a frozen bundle with the documented instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundle = createHeaderResolverInstruments();
|
||||
expect(Object.isFrozen(bundle)).toBe(true);
|
||||
expect(bundle.meter).toBeDefined();
|
||||
expect(bundle.resolvesTotal).toBeDefined();
|
||||
expect(typeof bundle.resolvesTotal.add).toBe("function");
|
||||
});
|
||||
|
||||
it("counter accepts add() without throwing under the global no-op SDK", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundle = createHeaderResolverInstruments();
|
||||
expect(() =>
|
||||
bundle.resolvesTotal.add(1, {
|
||||
resolver: "header",
|
||||
outcome: "resolved",
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses an injected meter provider when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let getMeterCalls = 0;
|
||||
const meterProvider = {
|
||||
getMeter() {
|
||||
getMeterCalls += 1;
|
||||
return {
|
||||
createCounter: () => ({ add: () => undefined }) as never,
|
||||
} as never;
|
||||
},
|
||||
} as never;
|
||||
createHeaderResolverInstruments({ meterProvider });
|
||||
expect(getMeterCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Frozen OTel instrument bundle emitted by the header tenant resolver.
|
||||
*
|
||||
* `resolvesTotal` increments once per `resolve()` invocation; every
|
||||
* observation carries a `resolver="header"` attribute plus an `outcome`
|
||||
* attribute drawn from {@link HeaderResolveOutcome}. The instrument name
|
||||
* `api.tenant.resolves_total` is the canonical counter from the API plugin
|
||||
* foundation design doc (§6 step 4).
|
||||
*/
|
||||
export interface HeaderResolverInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly resolvesTotal: Counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome attribute attached to every `resolvesTotal` observation.
|
||||
*
|
||||
* - `resolved` — header present, validated, registry-checked, returned a
|
||||
* `TenantContext`.
|
||||
* - `absent` — header missing; resolver returned `null`.
|
||||
* - `invalid` — header value failed the validator.
|
||||
* - `not_found_in_registry` — value passed validator but is not in the
|
||||
* configured registry.
|
||||
* - `untrusted_source` — request `metadata.source` is not in
|
||||
* `trustedSources`.
|
||||
*/
|
||||
export type HeaderResolveOutcome =
|
||||
| "resolved"
|
||||
| "absent"
|
||||
| "invalid"
|
||||
| "not_found_in_registry"
|
||||
| "untrusted_source";
|
||||
|
||||
const METER_NAME = "@suaveplan/api-tenant-resolver-header";
|
||||
|
||||
/**
|
||||
* Build the resolver's instrument registry. Defaults to the OTel global
|
||||
* meter provider (which falls back to no-op when no SDK is installed) so
|
||||
* unit tests need not wire a provider.
|
||||
*/
|
||||
export function createHeaderResolverInstruments(config?: {
|
||||
readonly meterProvider?: MeterProvider;
|
||||
}): HeaderResolverInstruments {
|
||||
const meterProvider = config?.meterProvider ?? metrics.getMeterProvider();
|
||||
const meter = meterProvider.getMeter(METER_NAME);
|
||||
const resolvesTotal = meter.createCounter("api.tenant.resolves_total");
|
||||
return Object.freeze({ meter, resolvesTotal });
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type HeaderTenantResolverOptions,
|
||||
parseHeaderTenantResolverOptions,
|
||||
type ResolvedHeaderTenantResolverOptions,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# `options`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod-validated options schema accepted by
|
||||
`createHeaderTenantResolver`. Construction-time validation guarantees a host
|
||||
mis-config raises a stable `TENANT_HEADER.INVALID_OPTIONS` code rather than a
|
||||
runtime `TypeError` on the first request, and downstream code consumes the
|
||||
resolved option bundle (`ResolvedHeaderTenantResolverOptions`) which has
|
||||
every field populated with a default.
|
||||
|
||||
The schema is `.strict()` so unknown keys raise — typos in `headername`,
|
||||
`trusted_sources`, or `requirePrincipal` (a JWT-resolver option that has no
|
||||
meaning here) fail loudly at construction.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero-config defaults: `id="header"`, `headerName="x-tenant-id"`,
|
||||
`strict=false`, `trustedSources=["service-mesh"]`.
|
||||
- Strict Zod object (unknown keys rejected) so option name typos surface as
|
||||
`INVALID_OPTIONS` rather than silently no-oping.
|
||||
- `headerName` normalised to lowercase at parse time so the resolver's
|
||||
`Headers.get(...)` lookup is case-insensitive in a single place.
|
||||
- `trustedSources` defensively frozen so the resolver never mutates the
|
||||
caller's array.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseHeaderTenantResolverOptions } from "@suaveplan/api-tenant-resolver-header/options";
|
||||
|
||||
const opts = parseHeaderTenantResolverOptions(undefined);
|
||||
// → { id: "header", headerName: "x-tenant-id", strict: false,
|
||||
// trustedSources: ["service-mesh"] }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-header/error-codes";
|
||||
import { parseHeaderTenantResolverOptions } from "@suaveplan/api-tenant-resolver-header/options";
|
||||
|
||||
try {
|
||||
parseHeaderTenantResolverOptions({
|
||||
id: "primary",
|
||||
headerName: "X-Acme-Tenant",
|
||||
strict: true,
|
||||
trustedSources: ["service-mesh", "internal-gateway"],
|
||||
});
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof HeaderTenantResolverError &&
|
||||
e.code === TENANT_HEADER_CODES.INVALID_OPTIONS
|
||||
) {
|
||||
// surface to operator as a 5xx
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HeaderTenantResolverOptions` — all fields optional; describes
|
||||
user input.
|
||||
- `interface ResolvedHeaderTenantResolverOptions` — every field required;
|
||||
describes the parsed bundle the resolver consumes internally.
|
||||
- `function parseHeaderTenantResolverOptions(input)
|
||||
: ResolvedHeaderTenantResolverOptions`
|
||||
— throws `HeaderTenantResolverError(INVALID_OPTIONS)` with the underlying
|
||||
`ZodError` attached as `cause` on any validation failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`headerName` is lowercased because the WHATWG `Headers` object's `get`
|
||||
method is case-insensitive — keeping the configured value lowercase means
|
||||
direct string comparison stays correct in the trust-source path (where the
|
||||
resolver references `resolved.headerName` for diagnostic messages).
|
||||
`trustedSources` is frozen after parsing so the resolver's runtime path can
|
||||
share the same array across requests without defensive copying.
|
||||
|
||||
The default `trustedSources` of `["service-mesh"]` aligns with the design
|
||||
intent that a tenant header is a service-to-service contract, never an
|
||||
end-user-controllable input. Operators who need to widen the trust set must
|
||||
do so explicitly in host config; there is no default that admits browser
|
||||
traffic.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseHeaderTenantResolverOptions } from "./options.js";
|
||||
|
||||
describe("parseHeaderTenantResolverOptions", () => {
|
||||
it("fills defaults when input is undefined", { timeout: 5000 }, () => {
|
||||
const parsed = parseHeaderTenantResolverOptions(undefined);
|
||||
expect(parsed.id).toBe("header");
|
||||
expect(parsed.headerName).toBe("x-tenant-id");
|
||||
expect(parsed.strict).toBe(false);
|
||||
expect(parsed.trustedSources).toEqual(["service-mesh"]);
|
||||
});
|
||||
|
||||
it("fills defaults when input is empty object", { timeout: 5000 }, () => {
|
||||
const parsed = parseHeaderTenantResolverOptions({});
|
||||
expect(parsed).toEqual({
|
||||
id: "header",
|
||||
headerName: "x-tenant-id",
|
||||
strict: false,
|
||||
trustedSources: ["service-mesh"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalises headerName to lowercase", { timeout: 5000 }, () => {
|
||||
const parsed = parseHeaderTenantResolverOptions({
|
||||
headerName: "X-Acme-Tenant",
|
||||
});
|
||||
expect(parsed.headerName).toBe("x-acme-tenant");
|
||||
});
|
||||
|
||||
it("honours explicit overrides", { timeout: 5000 }, () => {
|
||||
const parsed = parseHeaderTenantResolverOptions({
|
||||
id: "primary",
|
||||
headerName: "x-tid",
|
||||
strict: true,
|
||||
trustedSources: ["a", "b"],
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
id: "primary",
|
||||
headerName: "x-tid",
|
||||
strict: true,
|
||||
trustedSources: ["a", "b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("freezes the trustedSources array", { timeout: 5000 }, () => {
|
||||
const parsed = parseHeaderTenantResolverOptions({
|
||||
trustedSources: ["a"],
|
||||
});
|
||||
expect(Object.isFrozen(parsed.trustedSources)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty string id", { timeout: 5000 }, () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
parseHeaderTenantResolverOptions({ id: "" });
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
|
||||
expect((thrown as HeaderTenantResolverError).code).toBe(
|
||||
TENANT_HEADER_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty headerName", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseHeaderTenantResolverOptions({ headerName: "" })
|
||||
).toThrow(HeaderTenantResolverError);
|
||||
});
|
||||
|
||||
it("rejects non-boolean strict", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseHeaderTenantResolverOptions({
|
||||
strict: "yes" as unknown as boolean,
|
||||
})
|
||||
).toThrow(HeaderTenantResolverError);
|
||||
});
|
||||
|
||||
it("rejects empty entry in trustedSources", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseHeaderTenantResolverOptions({
|
||||
trustedSources: [""],
|
||||
})
|
||||
).toThrow(HeaderTenantResolverError);
|
||||
});
|
||||
|
||||
it("rejects unknown keys under strict mode", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseHeaderTenantResolverOptions({
|
||||
header_name: "x",
|
||||
} as unknown as Parameters<
|
||||
typeof parseHeaderTenantResolverOptions
|
||||
>[0])
|
||||
).toThrow(HeaderTenantResolverError);
|
||||
});
|
||||
|
||||
it("carries the ZodError as cause", { timeout: 5000 }, () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
parseHeaderTenantResolverOptions({ id: "" });
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect((thrown as HeaderTenantResolverError).cause).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Public options accepted by `createHeaderTenantResolver`.
|
||||
*
|
||||
* `headerName` selects which HTTP header carries the tenant identifier;
|
||||
* defaults to `X-Tenant-Id` so the common path is zero-configuration. The
|
||||
* lookup is case-insensitive — the resolver normalises both the configured
|
||||
* name and the inbound request header to lowercase.
|
||||
*
|
||||
* `strict` toggles raise-vs-advance behaviour for every failure mode (invalid
|
||||
* value, untrusted source, registry miss). When `strict: true` the resolver
|
||||
* throws a {@link HeaderTenantResolverError}; when `strict: false` (default)
|
||||
* it returns `null` so a downstream resolver may match.
|
||||
*
|
||||
* `trustedSources` lists the request-metadata `source` values that are
|
||||
* allowed to set the tenant header. Defaults to `["service-mesh"]` so
|
||||
* end-user traffic (sources `"edge"`, `"public"`, `"browser"`, …) cannot
|
||||
* spoof the tenant unless an operator explicitly opts in.
|
||||
*
|
||||
* `id` overrides the resolver's registered id used in chain logs and metric
|
||||
* tags.
|
||||
*/
|
||||
export interface HeaderTenantResolverOptions {
|
||||
readonly id?: string;
|
||||
readonly headerName?: string;
|
||||
readonly strict?: boolean;
|
||||
readonly trustedSources?: readonly string[];
|
||||
}
|
||||
|
||||
const optionsSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
headerName: z.string().min(1).optional(),
|
||||
strict: z.boolean().optional(),
|
||||
trustedSources: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export interface ResolvedHeaderTenantResolverOptions {
|
||||
readonly id: string;
|
||||
readonly headerName: string;
|
||||
readonly strict: boolean;
|
||||
readonly trustedSources: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate user-supplied options at construction time. Throws
|
||||
* `HeaderTenantResolverError(INVALID_OPTIONS)` on any Zod failure so the
|
||||
* host surfaces a stable code rather than a raw `ZodError`.
|
||||
*/
|
||||
export function parseHeaderTenantResolverOptions(
|
||||
input: HeaderTenantResolverOptions | undefined
|
||||
): ResolvedHeaderTenantResolverOptions {
|
||||
const parsed = optionsSchema.safeParse(input ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new HeaderTenantResolverError(
|
||||
TENANT_HEADER_CODES.INVALID_OPTIONS,
|
||||
`Invalid HeaderTenantResolverOptions: ${parsed.error.message}`,
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: parsed.data.id ?? "header",
|
||||
headerName: (parsed.data.headerName ?? "X-Tenant-Id").toLowerCase(),
|
||||
strict: parsed.data.strict ?? false,
|
||||
trustedSources: Object.freeze([
|
||||
...(parsed.data.trustedSources ?? ["service-mesh"]),
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type CreateHeaderTenantResolverOptions,
|
||||
createHeaderTenantResolver,
|
||||
} from "./resolver.js";
|
||||
@@ -0,0 +1,108 @@
|
||||
# `resolver`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements The tenant resolver factory `createHeaderTenantResolver`.
|
||||
The resolver reads the tenant identifier from a configurable HTTP header
|
||||
(default `X-Tenant-Id`), enforces trusted-source metadata, runs a
|
||||
configurable validator, and (optionally) consults a registry probe before
|
||||
projecting the value into a `TenantContext` for the host's per-request
|
||||
pipeline. Each invocation emits exactly one
|
||||
`api.tenant.resolves_total{resolver="header", outcome}` counter increment.
|
||||
|
||||
The resolver short-circuits in the order: absent → untrusted → invalid →
|
||||
registry → resolved. The order matters because untrusted-source enforcement
|
||||
must happen BEFORE the value is examined so a misconfigured client never
|
||||
sees the validator's regex pattern echoed back in an error message.
|
||||
|
||||
## Features
|
||||
|
||||
- `createHeaderTenantResolver(opts?)` factory returning a `TenantResolver`
|
||||
that conforms to `@suaveplan/api-types/tenant-resolver`.
|
||||
- Configurable `headerName` (case-insensitive); defaults to `X-Tenant-Id`.
|
||||
- Injectable `validate` function — defaults to `^[a-z0-9-]{1,64}$`.
|
||||
- Optional `registry` probe (sync or async) — fired only after validator
|
||||
passes so an attacker probing for tenant existence cannot bypass shape
|
||||
enforcement.
|
||||
- Trusted-source gate via injectable `getMetadata` extractor; defaults to
|
||||
reading `request.metadata.source`.
|
||||
- Strict-mode toggle: raise `TENANT_HEADER_*` errors vs return `null` so a
|
||||
downstream resolver may take over.
|
||||
- OTel counter `api.tenant.resolves_total{resolver="header",outcome}` —
|
||||
every code path increments exactly once per `resolve()` call.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header/resolver";
|
||||
|
||||
const resolver = createHeaderTenantResolver();
|
||||
// caller wires the request after ingress middleware stamps the metadata:
|
||||
(request as Request & { metadata: { source: string } }).metadata = {
|
||||
source: "service-mesh",
|
||||
};
|
||||
const ctx = await resolver.resolve(request);
|
||||
// → { id: "acme", slug: "acme" } | null
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
|
||||
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header/resolver";
|
||||
import type { TenantRegistryProbe } from "@suaveplan/api-tenant-resolver-header/validate";
|
||||
|
||||
declare const provider: TenantProvider;
|
||||
const registry: TenantRegistryProbe = async (tid) =>
|
||||
(await provider.lookup(tid as never)) !== null;
|
||||
|
||||
const resolver = createHeaderTenantResolver({
|
||||
id: "header-primary",
|
||||
headerName: "X-Acme-Tenant",
|
||||
strict: true,
|
||||
trustedSources: ["service-mesh", "internal-gateway"],
|
||||
validate: (v) => /^acme-[a-z0-9]{8}$/.test(v),
|
||||
registry,
|
||||
getMetadata: (req) => sidecar.get(req) ?? null,
|
||||
});
|
||||
|
||||
const ctx = await resolver.resolve(request);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateHeaderTenantResolverOptions extends HeaderTenantResolverOptions`:
|
||||
- `validate?: TenantIdValidator`
|
||||
- `registry?: TenantRegistryProbe`
|
||||
- `getMetadata?: RequestMetadataExtractor`
|
||||
- `instruments?: HeaderResolverInstruments`
|
||||
- `function createHeaderTenantResolver(options?): TenantResolver` — returns
|
||||
`{ id, resolve(request) }`. `resolve` returns
|
||||
`Promise<TenantContext | null>`; throws
|
||||
`HeaderTenantResolverError(INVALID | UNTRUSTED_SOURCE |
|
||||
NOT_FOUND_IN_REGISTRY)` only when `strict: true`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The resolver checks **trusted-source before validation** so a public client
|
||||
sending `X-Tenant-Id: <attack-payload>` never reaches the validator's
|
||||
diagnostic path. This is a defence-in-depth choice — the validator itself
|
||||
should not be exploitable, but minimising the surface that untrusted input
|
||||
reaches narrows the audit footprint.
|
||||
|
||||
The OTel counter increments **exactly once** per `resolve()` call. Every
|
||||
branch — including the `absent` and `null`-return non-strict paths — calls
|
||||
`increment` exactly once. The `outcome` attribute makes the breakdown
|
||||
queryable from dashboards: a sudden spike in `outcome="invalid"` signals
|
||||
either a misconfigured client or a probing attacker, and a spike in
|
||||
`outcome="untrusted_source"` signals that an ingress adapter has stopped
|
||||
stamping `source` correctly.
|
||||
|
||||
The brand-cast to `TenantId` happens at the trust boundary: the value has
|
||||
passed the validator (shape) and the registry (existence) when the cast
|
||||
fires. Consumers downstream get an opaque branded string they cannot have
|
||||
fabricated from user input.
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Self-conformance for the `TenantResolver` contract: the header resolver
|
||||
* MUST (a) expose a non-empty `id`, (b) return `Promise<TenantContext | null>`
|
||||
* shape on every call, (c) never mutate the inbound `Request`, (d) be safe
|
||||
* to call concurrently across many requests sharing the same factory.
|
||||
*
|
||||
* `@suaveplan/api-types/tenant-resolver` does not (yet) ship a conformance
|
||||
* harness; this file stands in as one, mirroring the sibling
|
||||
* `@suaveplan/api-tenant-resolver-jwt` pattern. The spec delta references
|
||||
* `runTenantResolverConformance` from `api-tenant-provider/testing` for
|
||||
* forward compatibility — when that harness lands, this file flips to a
|
||||
* thin wrapper that calls it.
|
||||
*/
|
||||
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createHeaderTenantResolver } from "./resolver.js";
|
||||
|
||||
function makeRequest(headerValue: string | undefined): Request {
|
||||
const headers: Record<string, string> = {};
|
||||
if (headerValue !== undefined) headers["X-Tenant-Id"] = headerValue;
|
||||
const req = new Request(`https://example.com/${Math.random()}`, {
|
||||
headers,
|
||||
});
|
||||
Object.defineProperty(req, "metadata", {
|
||||
value: { source: "service-mesh" },
|
||||
enumerable: true,
|
||||
});
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("header resolver passes runTenantResolverConformance", () => {
|
||||
const factory = (): TenantResolver => createHeaderTenantResolver();
|
||||
|
||||
it("exposes a non-empty id", { timeout: 5000 }, () => {
|
||||
expect(factory().id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns a TenantContext when header is present and trusted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = await factory().resolve(makeRequest("acme"));
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.id).toBe("acme");
|
||||
expect(typeof ctx?.slug).toBe("string");
|
||||
});
|
||||
|
||||
it("returns null (chain advance) when header is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
expect(await factory().resolve(makeRequest(undefined))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not mutate the inbound Request", { timeout: 5000 }, async () => {
|
||||
const req = makeRequest("acme");
|
||||
const before = Object.keys(req).sort();
|
||||
await factory().resolve(req);
|
||||
expect(Object.keys(req).sort()).toEqual(before);
|
||||
});
|
||||
|
||||
it("is safe to call concurrently", { timeout: 5000 }, async () => {
|
||||
const resolver = factory();
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 32 }, (_, i) =>
|
||||
resolver.resolve(makeRequest(`t${i}`))
|
||||
)
|
||||
);
|
||||
expect(results).toHaveLength(32);
|
||||
for (const [i, ctx] of results.entries()) {
|
||||
expect(ctx?.id).toBe(`t${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { Counter } from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
HeaderResolveOutcome,
|
||||
HeaderResolverInstruments,
|
||||
} from "../instruments/instruments.js";
|
||||
import { createHeaderResolverInstruments } from "../instruments/instruments.js";
|
||||
import type { RequestTrustMetadata } from "../trust/trust.js";
|
||||
import { createHeaderTenantResolver } from "./resolver.js";
|
||||
|
||||
interface CapturedObservation {
|
||||
outcome: HeaderResolveOutcome;
|
||||
resolver: string;
|
||||
}
|
||||
|
||||
function captureInstruments(): {
|
||||
instruments: HeaderResolverInstruments;
|
||||
observations: CapturedObservation[];
|
||||
} {
|
||||
const base = createHeaderResolverInstruments();
|
||||
const observations: CapturedObservation[] = [];
|
||||
const wrapped: Counter = {
|
||||
...base.resolvesTotal,
|
||||
add(_value: number, attrs?: Record<string, unknown>) {
|
||||
observations.push({
|
||||
outcome: (attrs?.outcome as HeaderResolveOutcome) ?? "resolved",
|
||||
resolver: (attrs?.resolver as string) ?? "unknown",
|
||||
});
|
||||
},
|
||||
} as Counter;
|
||||
return {
|
||||
instruments: { meter: base.meter, resolvesTotal: wrapped },
|
||||
observations,
|
||||
};
|
||||
}
|
||||
|
||||
function reqWith(
|
||||
headers: Record<string, string>,
|
||||
metadata: RequestTrustMetadata | null | undefined = {
|
||||
source: "service-mesh",
|
||||
}
|
||||
): Request {
|
||||
const req = new Request("https://example.com/x", { headers });
|
||||
if (metadata !== undefined) {
|
||||
Object.defineProperty(req, "metadata", {
|
||||
value: metadata,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("createHeaderTenantResolver", () => {
|
||||
it("well-formed header resolves", { timeout: 5000 }, async () => {
|
||||
const { instruments, observations } = captureInstruments();
|
||||
const resolver = createHeaderTenantResolver({ instruments });
|
||||
const ctx = await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
|
||||
expect(ctx).toEqual({ id: "acme", slug: "acme" });
|
||||
expect(observations).toEqual([
|
||||
{ resolver: "header", outcome: "resolved" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("missing header returns null (chain advance)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { instruments, observations } = captureInstruments();
|
||||
const resolver = createHeaderTenantResolver({ instruments });
|
||||
const ctx = await resolver.resolve(reqWith({}));
|
||||
expect(ctx).toBeNull();
|
||||
expect(observations).toEqual([
|
||||
{ resolver: "header", outcome: "absent" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("strict mode raises 400 on invalid", { timeout: 5000 }, async () => {
|
||||
const resolver = createHeaderTenantResolver({ strict: true });
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await resolver.resolve(reqWith({ "X-Tenant-Id": "INVALID!" }));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
|
||||
expect((thrown as HeaderTenantResolverError).code).toBe(
|
||||
TENANT_HEADER_CODES.INVALID
|
||||
);
|
||||
});
|
||||
|
||||
it("non-strict mode returns null on invalid", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { instruments, observations } = captureInstruments();
|
||||
const resolver = createHeaderTenantResolver({ instruments });
|
||||
const ctx = await resolver.resolve(
|
||||
reqWith({ "X-Tenant-Id": "INVALID!" })
|
||||
);
|
||||
expect(ctx).toBeNull();
|
||||
expect(observations[0]?.outcome).toBe("invalid");
|
||||
});
|
||||
|
||||
it("well-formed but unknown raises NOT_FOUND_IN_REGISTRY in strict", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver({
|
||||
strict: true,
|
||||
registry: () => false,
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await resolver.resolve(
|
||||
reqWith({ "X-Tenant-Id": "unknown-tenant-1" })
|
||||
);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
|
||||
expect((thrown as HeaderTenantResolverError).code).toBe(
|
||||
TENANT_HEADER_CODES.NOT_FOUND_IN_REGISTRY
|
||||
);
|
||||
});
|
||||
|
||||
it("registry hit yields TenantContext", { timeout: 5000 }, async () => {
|
||||
const known = new Set(["acme", "beta"]);
|
||||
const resolver = createHeaderTenantResolver({
|
||||
registry: async (tid) => known.has(tid),
|
||||
});
|
||||
const ctx = await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
|
||||
expect(ctx).toEqual({ id: "acme", slug: "acme" });
|
||||
});
|
||||
|
||||
it("registry miss returns null in non-strict mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { instruments, observations } = captureInstruments();
|
||||
const resolver = createHeaderTenantResolver({
|
||||
instruments,
|
||||
registry: () => false,
|
||||
});
|
||||
const ctx = await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
|
||||
expect(ctx).toBeNull();
|
||||
expect(observations[0]?.outcome).toBe("not_found_in_registry");
|
||||
});
|
||||
|
||||
it("supports configurable headerName (case-insensitive)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver({
|
||||
headerName: "X-Acme-Tenant",
|
||||
});
|
||||
const ctx = await resolver.resolve(
|
||||
reqWith({ "x-acme-tenant": "acme" })
|
||||
);
|
||||
expect(ctx).toEqual({ id: "acme", slug: "acme" });
|
||||
});
|
||||
|
||||
it("honours custom validator", { timeout: 5000 }, async () => {
|
||||
const resolver = createHeaderTenantResolver({
|
||||
validate: (v) => v === "exact-only",
|
||||
});
|
||||
expect(
|
||||
await resolver.resolve(reqWith({ "X-Tenant-Id": "exact-only" }))
|
||||
).toEqual({ id: "exact-only", slug: "exact-only" });
|
||||
expect(
|
||||
await resolver.resolve(reqWith({ "X-Tenant-Id": "anything-else" }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the configured id", { timeout: 5000 }, () => {
|
||||
expect(createHeaderTenantResolver().id).toBe("header");
|
||||
expect(createHeaderTenantResolver({ id: "primary" }).id).toBe(
|
||||
"primary"
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates an INVALID_OPTIONS error for bad input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => createHeaderTenantResolver({ headerName: "" })).toThrow(
|
||||
HeaderTenantResolverError
|
||||
);
|
||||
});
|
||||
|
||||
it("untrusted source is checked BEFORE validator (defence in depth)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { instruments, observations } = captureInstruments();
|
||||
const resolver = createHeaderTenantResolver({
|
||||
instruments,
|
||||
// intentionally an invalid value AND an untrusted source — the
|
||||
// untrusted-source path must win so the validator never sees the
|
||||
// attack-controlled value.
|
||||
});
|
||||
const ctx = await resolver.resolve(
|
||||
reqWith({ "X-Tenant-Id": "INVALID!" }, { source: "edge" })
|
||||
);
|
||||
expect(ctx).toBeNull();
|
||||
expect(observations[0]?.outcome).toBe("untrusted_source");
|
||||
});
|
||||
|
||||
it("empty header value is treated as absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { instruments, observations } = captureInstruments();
|
||||
const resolver = createHeaderTenantResolver({ instruments });
|
||||
// Manually set an empty header value via Headers init.
|
||||
const headers = new Headers();
|
||||
headers.set("X-Tenant-Id", "");
|
||||
const req = new Request("https://example.com/x", { headers });
|
||||
Object.defineProperty(req, "metadata", {
|
||||
value: { source: "service-mesh" },
|
||||
enumerable: true,
|
||||
});
|
||||
const ctx = await resolver.resolve(req);
|
||||
expect(ctx).toBeNull();
|
||||
expect(observations[0]?.outcome).toBe("absent");
|
||||
});
|
||||
|
||||
it("honours an explicit getMetadata extractor (WeakMap pattern)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sidecar = new WeakMap<Request, RequestTrustMetadata>();
|
||||
const resolver = createHeaderTenantResolver({
|
||||
getMetadata: (r) => sidecar.get(r) ?? null,
|
||||
});
|
||||
const req = new Request("https://example.com/x", {
|
||||
headers: { "X-Tenant-Id": "acme" },
|
||||
});
|
||||
sidecar.set(req, { source: "service-mesh" });
|
||||
expect(await resolver.resolve(req)).toEqual({
|
||||
id: "acme",
|
||||
slug: "acme",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits exactly one counter increment per resolve() call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { instruments, observations } = captureInstruments();
|
||||
const resolver = createHeaderTenantResolver({ instruments });
|
||||
await resolver.resolve(reqWith({ "X-Tenant-Id": "acme" }));
|
||||
await resolver.resolve(reqWith({}));
|
||||
await resolver.resolve(
|
||||
reqWith({ "X-Tenant-Id": "BAD!" }, { source: "service-mesh" })
|
||||
);
|
||||
expect(observations).toHaveLength(3);
|
||||
expect(observations.map((o) => o.outcome)).toEqual([
|
||||
"resolved",
|
||||
"absent",
|
||||
"invalid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("default trusted source is service-mesh only (fail-closed)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver();
|
||||
const req = reqWith({ "X-Tenant-Id": "acme" }, { source: "browser" });
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { TenantContext, TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createHeaderResolverInstruments,
|
||||
type HeaderResolveOutcome,
|
||||
type HeaderResolverInstruments,
|
||||
} from "../instruments/instruments.js";
|
||||
import {
|
||||
type HeaderTenantResolverOptions,
|
||||
parseHeaderTenantResolverOptions,
|
||||
} from "../options/options.js";
|
||||
import {
|
||||
defaultMetadataExtractor,
|
||||
isTrustedSource,
|
||||
type RequestMetadataExtractor,
|
||||
} from "../trust/trust.js";
|
||||
import {
|
||||
defaultTenantIdValidator,
|
||||
type TenantIdValidator,
|
||||
type TenantRegistryProbe,
|
||||
} from "../validate/validate.js";
|
||||
|
||||
/**
|
||||
* Full options object accepted by {@link createHeaderTenantResolver}.
|
||||
* Extends the Zod-validated {@link HeaderTenantResolverOptions} with the
|
||||
* injectable validator, registry, metadata extractor, and instrument
|
||||
* bundle.
|
||||
*/
|
||||
export interface CreateHeaderTenantResolverOptions
|
||||
extends HeaderTenantResolverOptions {
|
||||
/** Validator applied to the header value. Defaults to `^[a-z0-9-]{1,64}$`. */
|
||||
readonly validate?: TenantIdValidator;
|
||||
/** Optional registry membership probe; runs only after validator passes. */
|
||||
readonly registry?: TenantRegistryProbe;
|
||||
/** Override for the trust-metadata extractor. */
|
||||
readonly getMetadata?: RequestMetadataExtractor;
|
||||
/** Override for the OTel instrument bundle. */
|
||||
readonly instruments?: HeaderResolverInstruments;
|
||||
}
|
||||
|
||||
const ZOD_OPTION_KEYS = new Set<keyof HeaderTenantResolverOptions>([
|
||||
"id",
|
||||
"headerName",
|
||||
"strict",
|
||||
"trustedSources",
|
||||
]);
|
||||
|
||||
function splitOptions(options: CreateHeaderTenantResolverOptions | undefined): {
|
||||
zodInput: HeaderTenantResolverOptions | undefined;
|
||||
validate: TenantIdValidator;
|
||||
registry: TenantRegistryProbe | undefined;
|
||||
getMetadata: RequestMetadataExtractor;
|
||||
instruments: HeaderResolverInstruments;
|
||||
} {
|
||||
const input = options ?? {};
|
||||
const zodInput: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(input)) {
|
||||
if (ZOD_OPTION_KEYS.has(key as keyof HeaderTenantResolverOptions)) {
|
||||
zodInput[key] = (input as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
return {
|
||||
zodInput:
|
||||
Object.keys(zodInput).length === 0
|
||||
? undefined
|
||||
: (zodInput as HeaderTenantResolverOptions),
|
||||
validate: input.validate ?? defaultTenantIdValidator,
|
||||
registry: input.registry,
|
||||
getMetadata: input.getMetadata ?? defaultMetadataExtractor,
|
||||
instruments: input.instruments ?? createHeaderResolverInstruments(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Stage-8 `TenantResolver` that reads the tenant identifier from a
|
||||
* configurable HTTP header. The resolver runs four sequential checks:
|
||||
*
|
||||
* 1. Header presence — missing header yields `null` (chain advances).
|
||||
* 2. Trusted-source — `request.metadata.source` MUST be in
|
||||
* `trustedSources`. Strict-mode raises
|
||||
* `TENANT_HEADER.UNTRUSTED_SOURCE`; non-strict-mode returns `null`.
|
||||
* 3. Validator — value MUST match the configured regex / validator.
|
||||
* Strict-mode raises `TENANT_HEADER.INVALID`; non-strict-mode returns
|
||||
* `null`.
|
||||
* 4. Registry — when supplied, the value MUST be a member. Strict-mode
|
||||
* raises `TENANT_HEADER.NOT_FOUND_IN_REGISTRY`; non-strict-mode returns
|
||||
* `null`.
|
||||
*
|
||||
* Every outcome (`resolved`, `absent`, `invalid`, `not_found_in_registry`,
|
||||
* `untrusted_source`) increments
|
||||
* `api.tenant.resolves_total{resolver="header",outcome}` exactly once per
|
||||
* `resolve()` invocation.
|
||||
*/
|
||||
export function createHeaderTenantResolver(
|
||||
options?: CreateHeaderTenantResolverOptions
|
||||
): TenantResolver {
|
||||
const { zodInput, validate, registry, getMetadata, instruments } =
|
||||
splitOptions(options);
|
||||
const resolved = parseHeaderTenantResolverOptions(zodInput);
|
||||
|
||||
function increment(outcome: HeaderResolveOutcome): void {
|
||||
instruments.resolvesTotal.add(1, {
|
||||
resolver: "header",
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
|
||||
function strictThrow(
|
||||
code: (typeof TENANT_HEADER_CODES)[keyof typeof TENANT_HEADER_CODES],
|
||||
message: string
|
||||
): never {
|
||||
throw new HeaderTenantResolverError(code, message);
|
||||
}
|
||||
|
||||
return {
|
||||
id: resolved.id,
|
||||
async resolve(request: Request): Promise<TenantContext | null> {
|
||||
const rawValue = request.headers.get(resolved.headerName);
|
||||
if (rawValue === null || rawValue.length === 0) {
|
||||
increment("absent");
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = getMetadata(request) ?? null;
|
||||
if (!isTrustedSource(metadata, resolved.trustedSources)) {
|
||||
increment("untrusted_source");
|
||||
if (resolved.strict) {
|
||||
strictThrow(
|
||||
TENANT_HEADER_CODES.UNTRUSTED_SOURCE,
|
||||
`Header '${resolved.headerName}' is present but request source is not trusted (expected one of: ${resolved.trustedSources.join(", ")})`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!validate(rawValue)) {
|
||||
increment("invalid");
|
||||
if (resolved.strict) {
|
||||
strictThrow(
|
||||
TENANT_HEADER_CODES.INVALID,
|
||||
`Header '${resolved.headerName}' value did not pass validator`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (registry !== undefined) {
|
||||
const present = await registry(rawValue);
|
||||
if (!present) {
|
||||
increment("not_found_in_registry");
|
||||
if (resolved.strict) {
|
||||
strictThrow(
|
||||
TENANT_HEADER_CODES.NOT_FOUND_IN_REGISTRY,
|
||||
`Header '${resolved.headerName}' value '${rawValue}' is not present in the tenant registry`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
increment("resolved");
|
||||
const tenantId = rawValue as TenantId;
|
||||
return { id: tenantId, slug: rawValue };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
defaultMetadataExtractor,
|
||||
isTrustedSource,
|
||||
type RequestMetadataExtractor,
|
||||
type RequestTrustMetadata,
|
||||
} from "./trust.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# `trust`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Encapsulates the trusted-source enforcement consulted by the header tenant
|
||||
resolver before it acts on a header value. The check exists because an
|
||||
HTTP header is end-user-controllable on public traffic; without a
|
||||
trusted-source gate, any browser could spoof the tenant by sending
|
||||
`X-Tenant-Id: competitor`.
|
||||
|
||||
Upstream middleware (a service-mesh adapter, an mTLS-verified ingress,
|
||||
internal-network admission gate) stamps a `source` value on
|
||||
`request.metadata`. The resolver consults that stamp via `isTrustedSource`;
|
||||
when the source is missing or absent from the configured allow-list, the
|
||||
header is ignored (strict-mode raises `TENANT_HEADER.UNTRUSTED_SOURCE`,
|
||||
non-strict-mode returns `null` so the chain advances).
|
||||
|
||||
## Features
|
||||
|
||||
- `RequestTrustMetadata` shape — `{ source?: string }` minimal contract.
|
||||
- `RequestMetadataExtractor` injectable extractor with a fail-closed
|
||||
default that reads `request.metadata`.
|
||||
- `isTrustedSource(metadata, trustedSources)` — fail-closed boolean.
|
||||
Missing metadata, missing `source`, or a `source` not in the allow-list
|
||||
all yield `false`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultMetadataExtractor,
|
||||
isTrustedSource,
|
||||
} from "@suaveplan/api-tenant-resolver-header/trust";
|
||||
|
||||
const metadata = defaultMetadataExtractor(request);
|
||||
if (isTrustedSource(metadata, ["service-mesh"])) {
|
||||
// header may be consulted
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RequestMetadataExtractor,
|
||||
RequestTrustMetadata,
|
||||
} from "@suaveplan/api-tenant-resolver-header/trust";
|
||||
|
||||
const sidecar = new WeakMap<Request, RequestTrustMetadata>();
|
||||
|
||||
const fromSidecar: RequestMetadataExtractor = (req) =>
|
||||
sidecar.get(req) ?? null;
|
||||
|
||||
// ingress middleware stamps a frozen Request via a WeakMap rather than
|
||||
// mutating the request itself:
|
||||
sidecar.set(request, { source: "service-mesh" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RequestTrustMetadata` — `{ readonly source?: string }`.
|
||||
- `type RequestMetadataExtractor = (request: Request)
|
||||
=> RequestTrustMetadata | null | undefined`.
|
||||
- `const defaultMetadataExtractor: RequestMetadataExtractor` — reads
|
||||
`request.metadata`.
|
||||
- `function isTrustedSource(metadata, trustedSources): boolean` —
|
||||
fail-closed allow-list check.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isTrustedSource` returns `false` for absent metadata so the policy is
|
||||
fail-closed by default. The alternative — treating "no metadata" as
|
||||
"trust by default" — would mean a single misconfigured ingress (one that
|
||||
forgets to set `source`) opens the entire fleet to header spoofing. The
|
||||
trade-off is that hosts MUST wire an ingress adapter that stamps `source`
|
||||
before the resolver runs; the resolver's contract is explicit about this
|
||||
expectation.
|
||||
|
||||
The default extractor reads a mutable property because that is the simplest
|
||||
host wiring. Frozen-Request runtimes (Workers, certain edge platforms) pass
|
||||
an explicit extractor backed by a `WeakMap`. Either way, the trust check is
|
||||
identical — only the I/O changes.
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createHeaderTenantResolver } from "../resolver/resolver.js";
|
||||
import {
|
||||
defaultMetadataExtractor,
|
||||
isTrustedSource,
|
||||
type RequestTrustMetadata,
|
||||
} from "./trust.js";
|
||||
|
||||
function reqWith(metadata: RequestTrustMetadata | null | undefined): Request {
|
||||
const req = new Request("https://example.com/x", {
|
||||
headers: { "X-Tenant-Id": "acme" },
|
||||
});
|
||||
if (metadata !== undefined) {
|
||||
Object.defineProperty(req, "metadata", {
|
||||
value: metadata,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("isTrustedSource", () => {
|
||||
it("returns true when source is in trustedSources", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
isTrustedSource({ source: "service-mesh" }, ["service-mesh"])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when source is absent from trustedSources", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isTrustedSource({ source: "edge" }, ["service-mesh"])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when metadata is null", { timeout: 5000 }, () => {
|
||||
expect(isTrustedSource(null, ["service-mesh"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when metadata is undefined", { timeout: 5000 }, () => {
|
||||
expect(isTrustedSource(undefined, ["service-mesh"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when source field is missing", { timeout: 5000 }, () => {
|
||||
expect(isTrustedSource({}, ["service-mesh"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultMetadataExtractor", () => {
|
||||
it("reads request.metadata when present", { timeout: 5000 }, () => {
|
||||
const req = reqWith({ source: "service-mesh" });
|
||||
expect(defaultMetadataExtractor(req)).toEqual({
|
||||
source: "service-mesh",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when request has no metadata property", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const req = new Request("https://example.com/x");
|
||||
expect(defaultMetadataExtractor(req)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trusted-source enforcement (integration with resolver)", () => {
|
||||
it("untrusted source raises in strict mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver({ strict: true });
|
||||
const req = reqWith({ source: "edge" });
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await resolver.resolve(req);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HeaderTenantResolverError);
|
||||
expect((thrown as HeaderTenantResolverError).code).toBe(
|
||||
TENANT_HEADER_CODES.UNTRUSTED_SOURCE
|
||||
);
|
||||
});
|
||||
|
||||
it("untrusted source returns null in non-strict mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver();
|
||||
const req = reqWith({ source: "edge" });
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("trusted source resolves when value is well-formed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver();
|
||||
const req = reqWith({ source: "service-mesh" });
|
||||
expect(await resolver.resolve(req)).toEqual({
|
||||
id: "acme",
|
||||
slug: "acme",
|
||||
});
|
||||
});
|
||||
|
||||
it("missing metadata is treated as untrusted (fail-closed)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver();
|
||||
const req = reqWith(undefined);
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("operator-widened trustedSources admit additional sources", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createHeaderTenantResolver({
|
||||
trustedSources: ["service-mesh", "internal-gateway"],
|
||||
});
|
||||
const req = reqWith({ source: "internal-gateway" });
|
||||
expect(await resolver.resolve(req)).toEqual({
|
||||
id: "acme",
|
||||
slug: "acme",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Request-metadata shape consumed by the trust check. Upstream middleware
|
||||
* (typically an ingress / service-mesh adapter) attaches this object to the
|
||||
* `Request` so the tenant resolver can decide whether the request is
|
||||
* allowed to carry a tenant header.
|
||||
*
|
||||
* The shape is intentionally minimal — `source` is the only field this
|
||||
* resolver consults. Hosts MAY attach further metadata (correlation ids,
|
||||
* client cert subject) that other middleware reads.
|
||||
*/
|
||||
export interface RequestTrustMetadata {
|
||||
readonly source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extractor invoked once per request to look up the metadata attached by
|
||||
* upstream middleware. Defaults to reading
|
||||
* `(request as { metadata?: RequestTrustMetadata }).metadata` so the simple
|
||||
* wiring (mutating the request once at ingress) works out of the box;
|
||||
* production hosts pass an explicit extractor backed by a `WeakMap`.
|
||||
*/
|
||||
export type RequestMetadataExtractor = (
|
||||
request: Request
|
||||
) => RequestTrustMetadata | null | undefined;
|
||||
|
||||
/** Default metadata extractor — reads `request.metadata`. */
|
||||
export const defaultMetadataExtractor: RequestMetadataExtractor = (request) => {
|
||||
const carrier = request as unknown as {
|
||||
readonly metadata?: RequestTrustMetadata;
|
||||
};
|
||||
return carrier.metadata ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns `true` iff the request's metadata `source` is contained in
|
||||
* `trustedSources`. A missing metadata bundle, a missing `source`, or a
|
||||
* `source` value not present in `trustedSources` all yield `false` so the
|
||||
* default is fail-closed.
|
||||
*/
|
||||
export function isTrustedSource(
|
||||
metadata: RequestTrustMetadata | null | undefined,
|
||||
trustedSources: readonly string[]
|
||||
): boolean {
|
||||
if (metadata === null || metadata === undefined) return false;
|
||||
if (metadata.source === undefined) return false;
|
||||
return trustedSources.includes(metadata.source);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
DEFAULT_TENANT_ID_PATTERN,
|
||||
defaultTenantIdValidator,
|
||||
type TenantIdValidator,
|
||||
type TenantRegistryProbe,
|
||||
} from "./validate.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `validate`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the default tenant-id validator regex and the validator / registry
|
||||
function signatures consumed by `createHeaderTenantResolver`. Splitting the
|
||||
validator surface out of `resolver.ts` keeps the resolver implementation a
|
||||
pure orchestrator (regex check → registry probe → trust check → return) and
|
||||
lets hosts substitute alternative validators without monkey-patching the
|
||||
factory.
|
||||
|
||||
The default validator (`^[a-z0-9-]{1,64}$`) is deliberately conservative —
|
||||
it admits the shape of a typical slug-style identifier and rejects
|
||||
everything else. Hosts whose tenant ids are UUIDs, prefixed strings, or
|
||||
arbitrary unicode must pass an explicit `validate` option.
|
||||
|
||||
## Features
|
||||
|
||||
- `DEFAULT_TENANT_ID_PATTERN` exported as a `RegExp` so hosts can extend
|
||||
rather than re-derive (`new RegExp(...|extra-form)`).
|
||||
- `defaultTenantIdValidator` — boolean function backed by the pattern.
|
||||
- `TenantIdValidator` and `TenantRegistryProbe` type aliases so host wiring
|
||||
is explicit.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultTenantIdValidator } from "@suaveplan/api-tenant-resolver-header/validate";
|
||||
|
||||
defaultTenantIdValidator("acme"); // true
|
||||
defaultTenantIdValidator("ACME"); // false (uppercase rejected)
|
||||
defaultTenantIdValidator(""); // false (zero-length rejected)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TenantIdValidator,
|
||||
TenantRegistryProbe,
|
||||
} from "@suaveplan/api-tenant-resolver-header/validate";
|
||||
|
||||
const uuidValidator: TenantIdValidator = (v) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v);
|
||||
|
||||
const registry: TenantRegistryProbe = async (tid) =>
|
||||
(await tenantProvider.lookup(tid as never)) !== null;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const DEFAULT_TENANT_ID_PATTERN: RegExp` — `^[a-z0-9-]{1,64}$`.
|
||||
- `const defaultTenantIdValidator: TenantIdValidator` — applies the default
|
||||
pattern; returns boolean.
|
||||
- `type TenantIdValidator = (value: string) => boolean` — synchronous
|
||||
validator contract.
|
||||
- `type TenantRegistryProbe = (tenantId: string) => boolean | Promise<boolean>`
|
||||
— sync-or-async registry membership probe.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The validator is intentionally synchronous: the regex check is the
|
||||
zero-I/O happy path, and a synchronous boolean lets the resolver avoid an
|
||||
`await` micro-task in the common-case good-tenant path. Registry probes can
|
||||
be async because the host may back them with a real `TenantProvider.lookup`
|
||||
that hits a database; the resolver awaits the probe only after the regex
|
||||
check has already passed so a bad value never leaks into the registry call.
|
||||
|
||||
The default pattern's 64-character cap is the same maximum used by
|
||||
`api-tenant-provider`'s seed schema; widening it here without widening it
|
||||
there would produce values the resolver accepts but the registry refuses.
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DEFAULT_TENANT_ID_PATTERN,
|
||||
defaultTenantIdValidator,
|
||||
} from "./validate.js";
|
||||
|
||||
describe("defaultTenantIdValidator", () => {
|
||||
it("accepts lowercase slug-style identifiers", { timeout: 5000 }, () => {
|
||||
expect(defaultTenantIdValidator("acme")).toBe(true);
|
||||
expect(defaultTenantIdValidator("acme-corp")).toBe(true);
|
||||
expect(defaultTenantIdValidator("a")).toBe(true);
|
||||
expect(defaultTenantIdValidator("0")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects uppercase characters", { timeout: 5000 }, () => {
|
||||
expect(defaultTenantIdValidator("Acme")).toBe(false);
|
||||
expect(defaultTenantIdValidator("ACME")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty strings", { timeout: 5000 }, () => {
|
||||
expect(defaultTenantIdValidator("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects strings longer than 64 characters", { timeout: 5000 }, () => {
|
||||
expect(defaultTenantIdValidator("a".repeat(64))).toBe(true);
|
||||
expect(defaultTenantIdValidator("a".repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects punctuation other than hyphen", { timeout: 5000 }, () => {
|
||||
expect(defaultTenantIdValidator("a_b")).toBe(false);
|
||||
expect(defaultTenantIdValidator("a.b")).toBe(false);
|
||||
expect(defaultTenantIdValidator("a b")).toBe(false);
|
||||
expect(defaultTenantIdValidator("a/b")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_TENANT_ID_PATTERN", () => {
|
||||
it("matches the expected canonical form", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_TENANT_ID_PATTERN.source).toBe("^[a-z0-9-]{1,64}$");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Default tenant-id validator regex applied by the header resolver.
|
||||
*
|
||||
* Aligns with the `^[a-z0-9-]{1,64}$` spec in
|
||||
* `openspec/changes/api-tenant-resolver-header/proposal.md`: lowercase
|
||||
* alphanumerics + hyphens, 1-64 characters. Hosts override via the
|
||||
* `validate` option on `createHeaderTenantResolver` when their tenant
|
||||
* identifier shape diverges (e.g. UUIDs, prefixed slugs).
|
||||
*/
|
||||
export const DEFAULT_TENANT_ID_PATTERN: RegExp = /^[a-z0-9-]{1,64}$/;
|
||||
|
||||
/**
|
||||
* Validator function signature consumed by `createHeaderTenantResolver`.
|
||||
* Returns `true` to accept the value, `false` to reject. Throwing is
|
||||
* permitted and surfaces through the resolver's strict/non-strict branch
|
||||
* exactly like a `false` return.
|
||||
*/
|
||||
export type TenantIdValidator = (value: string) => boolean;
|
||||
|
||||
/** Default validator: lowercase a-z, digits, hyphen, 1-64 chars. */
|
||||
export const defaultTenantIdValidator: TenantIdValidator = (value: string) =>
|
||||
DEFAULT_TENANT_ID_PATTERN.test(value);
|
||||
|
||||
/**
|
||||
* Optional registry-membership probe. The resolver calls this AFTER the
|
||||
* regex validator passes. Returning `false` (or `Promise<false>`) surfaces
|
||||
* as `TENANT_HEADER.NOT_FOUND_IN_REGISTRY` (strict) or `null` (non-strict).
|
||||
*
|
||||
* Hosts typically back this with a `TenantProvider.lookup(tid)` call
|
||||
* (`@suaveplan/api-tenant-provider`), but the contract is intentionally
|
||||
* registry-shape-agnostic so hosts may plug in a static `Set` for tests or
|
||||
* a memoised cache.
|
||||
*/
|
||||
export type TenantRegistryProbe = (
|
||||
tenantId: string
|
||||
) => boolean | Promise<boolean>;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG = join(__dirname, "..", "package.json");
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
describe("@suaveplan/api-tenant-resolver-header package shape", () => {
|
||||
it("depends on the expected set of workspace + catalog packages", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(readFileSync(PKG, "utf8")) as {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
const deps = Object.keys(pkg.dependencies).sort();
|
||||
expect(deps).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every src file uses default-import z (no named import)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const offenders: string[] = [];
|
||||
walk(SRC, (file) => {
|
||||
if (!file.endsWith(".ts")) return;
|
||||
const content = readFileSync(file, "utf8");
|
||||
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(content)) {
|
||||
offenders.push(file);
|
||||
}
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("root barrel has no export-star", { timeout: 5000 }, () => {
|
||||
const content = readFileSync(join(SRC, "index.ts"), "utf8");
|
||||
expect(/export\s*\*/.test(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("no test file imports vitest / bun:test directly (rule 11)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const offenders: string[] = [];
|
||||
walk(SRC, (file) => {
|
||||
if (!file.endsWith(".test.ts")) return;
|
||||
const content = readFileSync(file, "utf8");
|
||||
if (
|
||||
/from\s*["'](?:vitest|bun:test)["']/.test(content) &&
|
||||
!/from\s*["']vitest\/config["']/.test(content)
|
||||
) {
|
||||
offenders.push(file);
|
||||
}
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function walk(dir: string, fn: (file: string) => void): void {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) walk(p, fn);
|
||||
else fn(p);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-tenant-resolver-header",
|
||||
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\//,
|
||||
/^@opentelemetry\//,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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