first commit
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry for `@suaveplan/api-tenant-resolver-jwt`.
|
||||
Every public failure path in the resolver raises a `JwtTenantResolverError`
|
||||
carrying one of two namespaced `TENANT_RESOLVER_JWT.*` codes, so downstream
|
||||
consumers (chain runners, observability sinks, error-mapping middleware) can
|
||||
branch deterministically on the string value rather than on a fragile
|
||||
`instanceof` chain or on `error.message` substring matches.
|
||||
|
||||
The registry is intentionally small — Stage 4 resolvers do almost no work
|
||||
beyond reading a claim — but it is exhaustive: any new failure path MUST
|
||||
land here first and then be wired through `JwtTenantResolverError` so the
|
||||
public surface stays in lockstep with the spec.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `TENANT_RESOLVER_JWT_CODES` object produced by `defineCodes` so the
|
||||
literal `"TENANT_RESOLVER_JWT.PRINCIPAL_MISSING_TENANT_ID"` is comparable
|
||||
at type-check time.
|
||||
- `JwtTenantResolverError` narrows `code` to `JwtTenantResolverErrorCode`,
|
||||
so a single `catch (e: unknown)` branch can refine straight to a known
|
||||
union without further casts.
|
||||
- Carries the `JwtTenantResolverErrorCode` redundantly on
|
||||
`jwtTenantResolverCode` for ergonomic destructuring in test harnesses
|
||||
where the generic `code` field is too widely typed.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
JwtTenantResolverError,
|
||||
TENANT_RESOLVER_JWT_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-jwt/error-codes";
|
||||
|
||||
try {
|
||||
// …call createJwtTenantResolver(...).resolve(req)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof JwtTenantResolverError) {
|
||||
if (error.code === TENANT_RESOLVER_JWT_CODES.PRINCIPAL_MISSING_TENANT_ID) {
|
||||
// strict-mode rejected — return a 401
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
JwtTenantResolverError,
|
||||
TENANT_RESOLVER_JWT_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-jwt/error-codes";
|
||||
|
||||
function classify(error: unknown): "invalid-options" | "missing-principal" | "other" {
|
||||
if (!(error instanceof JwtTenantResolverError)) return "other";
|
||||
switch (error.jwtTenantResolverCode) {
|
||||
case TENANT_RESOLVER_JWT_CODES.INVALID_OPTIONS:
|
||||
return "invalid-options";
|
||||
case TENANT_RESOLVER_JWT_CODES.PRINCIPAL_MISSING_TENANT_ID:
|
||||
return "missing-principal";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_RESOLVER_JWT_CODES` — `Readonly<Record<"PRINCIPAL_MISSING_TENANT_ID" | "INVALID_OPTIONS", string>>`.
|
||||
- `JwtTenantResolverErrorCode` — string union of the two registry values.
|
||||
- `JwtTenantResolverError extends SuaveplanError<JwtTenantResolverErrorCode>` —
|
||||
constructor `(code, message, options?)`; exposes `jwtTenantResolverCode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` produces a frozen registry where every value is the literal
|
||||
`"TENANT_RESOLVER_JWT.<KEY>"`. The class redundantly stores the code on
|
||||
`jwtTenantResolverCode` to side-step the wider `SuaveplanError<string>`
|
||||
typing that some toolchains see when the type parameter is erased through
|
||||
re-export. Codes are append-only — never rename or remove an entry, or
|
||||
downstream branches break silently across a version bump.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `options`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines and validates the `JwtTenantResolverOptions` accepted by
|
||||
`createJwtTenantResolver`. The options shape is deliberately narrow — Stage 4
|
||||
adapters do almost no work — but every field is Zod-validated at construction
|
||||
time so a typo in `claimKey` or a boolean coerced from a string never reaches
|
||||
the per-request hot path. Construction-time failures throw
|
||||
`JwtTenantResolverError(INVALID_OPTIONS)` so the host surfaces a stable error
|
||||
code rather than a raw `ZodError`.
|
||||
|
||||
The parser also fills in defaults (`id = "jwt"`, `claimKey = "tenantId"`,
|
||||
`requirePrincipal = false`), so the resolver implementation can treat its
|
||||
options as fully-resolved `Required<…>` and avoid scattering `??` defaults
|
||||
through the request path.
|
||||
|
||||
## Features
|
||||
|
||||
- `JwtTenantResolverOptions` — three optional fields covering the entire
|
||||
configuration surface (`id`, `claimKey`, `requirePrincipal`).
|
||||
- `parseJwtTenantResolverOptions(input)` — strict Zod parser; rejects
|
||||
unknown keys, empty strings, and non-boolean values.
|
||||
- Pure: no I/O, no side effects, no module-level state — safe to call from
|
||||
module-init code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseJwtTenantResolverOptions } from "@suaveplan/api-tenant-resolver-jwt/options";
|
||||
|
||||
const resolved = parseJwtTenantResolverOptions(undefined);
|
||||
// → { id: "jwt", claimKey: "tenantId", requirePrincipal: false }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseJwtTenantResolverOptions,
|
||||
type JwtTenantResolverOptions,
|
||||
} from "@suaveplan/api-tenant-resolver-jwt/options";
|
||||
import {
|
||||
JwtTenantResolverError,
|
||||
TENANT_RESOLVER_JWT_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-jwt/error-codes";
|
||||
|
||||
function safeParse(input: JwtTenantResolverOptions): "ok" | "bad" {
|
||||
try {
|
||||
parseJwtTenantResolverOptions(input);
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof JwtTenantResolverError &&
|
||||
e.code === TENANT_RESOLVER_JWT_CODES.INVALID_OPTIONS
|
||||
) {
|
||||
return "bad";
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface JwtTenantResolverOptions` — `{ id?: string; claimKey?: string; requirePrincipal?: boolean }`.
|
||||
- `function parseJwtTenantResolverOptions(input)` — `Required<Pick<JwtTenantResolverOptions, "id" | "claimKey" | "requirePrincipal">>`. Throws `JwtTenantResolverError(INVALID_OPTIONS)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Zod schema is `.strict()` — unknown keys reject at parse time. This is
|
||||
intentional: a downstream caller passing a misspelt field (e.g.
|
||||
`claim_key` instead of `claimKey`) would otherwise see the field silently
|
||||
ignored and the resolver fall back to defaults, masking the configuration
|
||||
bug behind a hard-to-trace "no tenant resolved" failure mode. The parser
|
||||
runs once at construction so the per-request hot path operates on
|
||||
fully-resolved `Required<…>` values without re-validation.
|
||||
@@ -0,0 +1,95 @@
|
||||
# `resolver`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements The tenant resolver factory `createJwtTenantResolver`. The
|
||||
resolver reads the tenant identifier from an already-resolved `Principal`
|
||||
attached by an earlier `api-auth-*` chain step and projects it into a
|
||||
`TenantContext`. When the configured claim is present the resolver returns
|
||||
that context (the chain short-circuits); when absent it returns `null` so a
|
||||
subsequent header / subdomain resolver can take over.
|
||||
|
||||
The resolver is intentionally trivial — it does no JWT verification of its
|
||||
own (that is `api-auth-jwt`'s job) and no network I/O. Its value is making
|
||||
the data-flow explicit: the host wires `auth → tenant-resolver-jwt → next`
|
||||
and the conformance suite gets to test the empty/missing-tenant edge cases
|
||||
uniformly without coupling the auth and tenant contracts.
|
||||
|
||||
## Features
|
||||
|
||||
- `createJwtTenantResolver(opts?)` factory returning a
|
||||
`TenantResolver` conforming to `@suaveplan/api-types/tenant-resolver`.
|
||||
- Dependency-injected `getPrincipal` extractor: defaults to reading
|
||||
`(request as { principal }).principal`, override for host wiring backed
|
||||
by a `WeakMap` or `AsyncLocalStorage`.
|
||||
- Configurable `claimKey`: defaults to `"tenantId"` (reads
|
||||
`Principal.tenantId`); any other key reads `Principal.claims[key]` and
|
||||
brand-casts the string to `TenantId`.
|
||||
- Strict-mode `requirePrincipal`: when `true`, a missing principal or a
|
||||
missing claim throws `TENANT_RESOLVER_JWT.PRINCIPAL_MISSING_TENANT_ID`
|
||||
rather than returning `null`.
|
||||
- Pure read-path: no module state, no async beyond the `Promise<…>` shape
|
||||
required by the contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createJwtTenantResolver } from "@suaveplan/api-tenant-resolver-jwt/resolver";
|
||||
|
||||
const resolver = createJwtTenantResolver();
|
||||
const ctx = await resolver.resolve(request);
|
||||
if (ctx) {
|
||||
// → { id: "acme" as TenantId, slug: "acme" }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { createJwtTenantResolver } from "@suaveplan/api-tenant-resolver-jwt/resolver";
|
||||
|
||||
const principals = new WeakMap<Request, Principal | null>();
|
||||
|
||||
const resolver = createJwtTenantResolver({
|
||||
id: "jwt-primary",
|
||||
claimKey: "tid",
|
||||
requirePrincipal: true,
|
||||
getPrincipal: (req) => principals.get(req) ?? null,
|
||||
});
|
||||
|
||||
// host wiring (api-auth-jwt would do this in production):
|
||||
principals.set(request, { id: "u" as never, scopes: [], claims: { tid: "acme" } });
|
||||
|
||||
const ctx = await resolver.resolve(request);
|
||||
// → { id: "acme" as TenantId, slug: "acme" } — short-circuits the chain
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type PrincipalExtractor = (request: Request) => Principal | null | undefined`.
|
||||
- `interface CreateJwtTenantResolverOptions extends JwtTenantResolverOptions { getPrincipal?: PrincipalExtractor }`.
|
||||
- `function createJwtTenantResolver(options?): TenantResolver` — returns
|
||||
`{ id, resolve(request) }`. `resolve` returns
|
||||
`Promise<TenantContext | null>`; throws
|
||||
`JwtTenantResolverError(PRINCIPAL_MISSING_TENANT_ID)` only when
|
||||
`requirePrincipal: true` and the principal / claim is absent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The resolver brand-casts the claim value to `TenantId` without further
|
||||
validation: the principal has already passed an `api-auth-*` adapter that
|
||||
owns the verification of the JWT signature and the trust boundary. Re-validating
|
||||
here would duplicate work the host already performed. Empty-string claim values
|
||||
are treated as absent so a misconfigured signer can never accidentally route
|
||||
all traffic into a tenant whose slug is the empty string.
|
||||
|
||||
The default `PrincipalExtractor` reads `request.principal`, which works for
|
||||
the simple host wiring (mutate the request once after auth resolves) but
|
||||
falls down for environments where the `Request` is frozen (Workers, certain
|
||||
edge runtimes). In those cases pass an explicit `getPrincipal` backed by a
|
||||
`WeakMap<Request, Principal>` populated by the auth middleware.
|
||||
Reference in New Issue
Block a user