Files
suaveplan/packages/api-stack/api/api-tenant-resolver-jwt/src/resolver/resolver.md
2026-05-27 23:29:53 +01:00

3.9 KiB

resolver

(no submodules)

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

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

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.