Initial Commit

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

View File

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

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
JwtTenantResolverError,
TENANT_RESOLVER_JWT_CODES,
} from "./error-codes.js";
describe("TENANT_RESOLVER_JWT_CODES registry", () => {
it("exposes exactly the two documented codes", { timeout: 5000 }, () => {
expect(Object.keys(TENANT_RESOLVER_JWT_CODES).sort()).toEqual([
"INVALID_OPTIONS",
"PRINCIPAL_MISSING_TENANT_ID",
]);
});
it("namespaces every code under TENANT_RESOLVER_JWT.*", {
timeout: 5000,
}, () => {
for (const value of Object.values(TENANT_RESOLVER_JWT_CODES)) {
expect(value.startsWith("TENANT_RESOLVER_JWT.")).toBe(true);
}
});
it("is frozen at the top level", { timeout: 5000 }, () => {
expect(Object.isFrozen(TENANT_RESOLVER_JWT_CODES)).toBe(true);
});
});
describe("JwtTenantResolverError", () => {
it("narrows code to JwtTenantResolverErrorCode", { timeout: 5000 }, () => {
const err = new JwtTenantResolverError(
TENANT_RESOLVER_JWT_CODES.INVALID_OPTIONS,
"boom"
);
expect(err.code).toBe("TENANT_RESOLVER_JWT.INVALID_OPTIONS");
expect(err.jwtTenantResolverCode).toBe(
TENANT_RESOLVER_JWT_CODES.INVALID_OPTIONS
);
expect(err.message).toBe("boom");
});
it("forwards cause via options", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new JwtTenantResolverError(
TENANT_RESOLVER_JWT_CODES.PRINCIPAL_MISSING_TENANT_ID,
"missing",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,41 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-tenant-resolver-jwt`.
*
* Every public failure path raises a {@link JwtTenantResolverError} carrying
* one of these namespaced `TENANT_RESOLVER_JWT.*` codes. Consumers branch on
* the string value (e.g. `error.code === TENANT_RESOLVER_JWT_CODES.PRINCIPAL_MISSING_TENANT_ID`).
*
* Codes:
* - `PRINCIPAL_MISSING_TENANT_ID` — strict-mode (`requirePrincipal: true`)
* fired but the request carries no principal (or its claim is empty).
* - `INVALID_OPTIONS` — `createJwtTenantResolver` called with options that
* fail Zod validation at construction time.
*/
export const TENANT_RESOLVER_JWT_CODES = defineCodes("TENANT_RESOLVER_JWT", [
"PRINCIPAL_MISSING_TENANT_ID",
"INVALID_OPTIONS",
] as const);
export type JwtTenantResolverErrorCode =
(typeof TENANT_RESOLVER_JWT_CODES)[keyof typeof TENANT_RESOLVER_JWT_CODES];
/**
* Concrete error class for the api-tenant-resolver-jwt package. Extends
* {@link SuaveplanError} so `instanceof JwtTenantResolverError` narrows
* `code` to {@link JwtTenantResolverErrorCode}.
*/
export class JwtTenantResolverError extends SuaveplanError<JwtTenantResolverErrorCode> {
public readonly jwtTenantResolverCode: JwtTenantResolverErrorCode;
public constructor(
code: JwtTenantResolverErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.jwtTenantResolverCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
JwtTenantResolverError,
type JwtTenantResolverErrorCode,
TENANT_RESOLVER_JWT_CODES,
} from "./error-codes.js";

View File

@@ -0,0 +1,14 @@
export {
JwtTenantResolverError,
type JwtTenantResolverErrorCode,
TENANT_RESOLVER_JWT_CODES,
} from "./error-codes/index.js";
export {
type JwtTenantResolverOptions,
parseJwtTenantResolverOptions,
} from "./options/index.js";
export {
type CreateJwtTenantResolverOptions,
createJwtTenantResolver,
type PrincipalExtractor,
} from "./resolver/index.js";

View File

@@ -0,0 +1,4 @@
export {
type JwtTenantResolverOptions,
parseJwtTenantResolverOptions,
} from "./options.js";

View File

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

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
JwtTenantResolverError,
TENANT_RESOLVER_JWT_CODES,
} from "../error-codes/error-codes.js";
import { parseJwtTenantResolverOptions } from "./options.js";
describe("parseJwtTenantResolverOptions", () => {
it("fills defaults when input is undefined", { timeout: 5000 }, () => {
expect(parseJwtTenantResolverOptions(undefined)).toEqual({
id: "jwt",
claimKey: "tenantId",
requirePrincipal: false,
});
});
it("fills defaults when input is empty object", { timeout: 5000 }, () => {
expect(parseJwtTenantResolverOptions({})).toEqual({
id: "jwt",
claimKey: "tenantId",
requirePrincipal: false,
});
});
it("honours explicit overrides", { timeout: 5000 }, () => {
expect(
parseJwtTenantResolverOptions({
id: "custom",
claimKey: "tid",
requirePrincipal: true,
})
).toEqual({ id: "custom", claimKey: "tid", requirePrincipal: true });
});
it("rejects empty string id", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseJwtTenantResolverOptions({ id: "" });
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(JwtTenantResolverError);
expect((thrown as JwtTenantResolverError).code).toBe(
TENANT_RESOLVER_JWT_CODES.INVALID_OPTIONS
);
});
it("rejects empty string claimKey", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseJwtTenantResolverOptions({ claimKey: "" });
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(JwtTenantResolverError);
});
it("rejects non-boolean requirePrincipal", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseJwtTenantResolverOptions({
requirePrincipal: "yes" as unknown as boolean,
});
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(JwtTenantResolverError);
});
it("rejects unknown keys under strict mode", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseJwtTenantResolverOptions({
claim_key: "tid",
} as unknown as Parameters<
typeof parseJwtTenantResolverOptions
>[0]);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(JwtTenantResolverError);
});
it("carries the ZodError as cause", { timeout: 5000 }, () => {
let thrown: unknown;
try {
parseJwtTenantResolverOptions({ id: "" });
} catch (e) {
thrown = e;
}
expect((thrown as JwtTenantResolverError).cause).toBeDefined();
});
});

View File

@@ -0,0 +1,55 @@
import z from "zod";
import {
JwtTenantResolverError,
TENANT_RESOLVER_JWT_CODES,
} from "../error-codes/error-codes.js";
/**
* Public options accepted by `createJwtTenantResolver`.
*
* `claimKey` selects which field on `Principal.claims` (or the projected
* `Principal.tenantId`) the resolver reads. Defaults to `"tenantId"` so the
* common path is zero-configuration. `requirePrincipal` toggles strict-mode:
* when `true`, a missing or principal-less request throws
* `TENANT_RESOLVER_JWT.PRINCIPAL_MISSING_TENANT_ID` rather than returning
* `null` (the chain-advance sentinel). `id` overrides the resolver's
* registered id used in logs / metrics tags.
*/
export interface JwtTenantResolverOptions {
readonly id?: string;
readonly claimKey?: string;
readonly requirePrincipal?: boolean;
}
const optionsSchema = z
.object({
id: z.string().min(1).optional(),
claimKey: z.string().min(1).optional(),
requirePrincipal: z.boolean().optional(),
})
.strict();
/**
* Parse and validate user-supplied options at construction time. Throws
* `JwtTenantResolverError(INVALID_OPTIONS)` on any Zod failure so the host
* surfaces a stable code rather than a raw `ZodError`.
*/
export function parseJwtTenantResolverOptions(
input: JwtTenantResolverOptions | undefined
): Required<
Pick<JwtTenantResolverOptions, "id" | "claimKey" | "requirePrincipal">
> {
const parsed = optionsSchema.safeParse(input ?? {});
if (!parsed.success) {
throw new JwtTenantResolverError(
TENANT_RESOLVER_JWT_CODES.INVALID_OPTIONS,
`Invalid JwtTenantResolverOptions: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
return {
id: parsed.data.id ?? "jwt",
claimKey: parsed.data.claimKey ?? "tenantId",
requirePrincipal: parsed.data.requirePrincipal ?? false,
};
}

View File

@@ -0,0 +1,5 @@
export {
type CreateJwtTenantResolverOptions,
createJwtTenantResolver,
type PrincipalExtractor,
} from "./resolver.js";

View File

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

View File

@@ -0,0 +1,164 @@
import type {
Principal,
PrincipalId,
Scope,
} from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
JwtTenantResolverError,
TENANT_RESOLVER_JWT_CODES,
} from "../error-codes/error-codes.js";
import { createJwtTenantResolver } from "./resolver.js";
function principalWith(overrides: Partial<Principal> = {}): Principal {
return {
id: "u_1" as PrincipalId,
scopes: [] as readonly Scope[],
claims: {},
...overrides,
};
}
function reqWith(principal: Principal | null): Request {
const req = new Request("https://example.com/x");
Object.defineProperty(req, "principal", {
value: principal,
enumerable: true,
});
return req;
}
describe("createJwtTenantResolver", () => {
it("returns TenantContext when principal carries tenantId", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver();
const req = reqWith(principalWith({ tenantId: "acme" as TenantId }));
const ctx = await resolver.resolve(req);
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
it("returns null when principal lacks tenantId (advance chain)", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver();
const req = reqWith(principalWith());
const ctx = await resolver.resolve(req);
expect(ctx).toBeNull();
});
it("returns null when principal is absent (advance chain)", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver();
const req = reqWith(null);
const ctx = await resolver.resolve(req);
expect(ctx).toBeNull();
});
it("returns null when default extractor cannot find principal property", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver();
const req = new Request("https://example.com/y");
const ctx = await resolver.resolve(req);
expect(ctx).toBeNull();
});
it("reads a custom claimKey from Principal.claims", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver({ claimKey: "tid" });
const req = reqWith(principalWith({ claims: { tid: "acme" } }));
const ctx = await resolver.resolve(req);
expect(ctx).toEqual({ id: "acme", slug: "acme" });
});
it("treats empty-string claim as absent", { timeout: 5000 }, async () => {
const resolver = createJwtTenantResolver({ claimKey: "tid" });
const req = reqWith(principalWith({ claims: { tid: "" } }));
expect(await resolver.resolve(req)).toBeNull();
});
it("treats non-string claim as absent", { timeout: 5000 }, async () => {
const resolver = createJwtTenantResolver({ claimKey: "tid" });
const req = reqWith(principalWith({ claims: { tid: 42 } }));
expect(await resolver.resolve(req)).toBeNull();
});
it("requirePrincipal=true throws when principal is null", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver({ requirePrincipal: true });
const req = reqWith(null);
await expect(resolver.resolve(req)).rejects.toBeInstanceOf(
JwtTenantResolverError
);
await resolver
.resolve(
reqWith(
principalWith({
tenantId: "ok" as TenantId,
})
)
)
.then((ctx) => expect(ctx).toEqual({ id: "ok", slug: "ok" }));
});
it("requirePrincipal=true throws when claim is missing", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver({
requirePrincipal: true,
claimKey: "tid",
});
const req = reqWith(principalWith({ claims: {} }));
let thrown: unknown;
try {
await resolver.resolve(req);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(JwtTenantResolverError);
expect((thrown as JwtTenantResolverError).code).toBe(
TENANT_RESOLVER_JWT_CODES.PRINCIPAL_MISSING_TENANT_ID
);
});
it("honours an explicit getPrincipal extractor", {
timeout: 5000,
}, async () => {
const store = new WeakMap<Request, Principal | null>();
const resolver = createJwtTenantResolver({
getPrincipal: (r) => store.get(r) ?? null,
});
const req = new Request("https://example.com/z");
store.set(req, principalWith({ tenantId: "via-map" as TenantId }));
const ctx = await resolver.resolve(req);
expect(ctx).toEqual({ id: "via-map", slug: "via-map" });
});
it("uses the configured id", { timeout: 5000 }, () => {
expect(createJwtTenantResolver().id).toBe("jwt");
expect(createJwtTenantResolver({ id: "primary" }).id).toBe("primary");
});
it("propagates an INVALID_OPTIONS error for bad input", {
timeout: 5000,
}, () => {
expect(() => createJwtTenantResolver({ claimKey: "" })).toThrow(
JwtTenantResolverError
);
});
it("treats undefined principal from extractor as null", {
timeout: 5000,
}, async () => {
const resolver = createJwtTenantResolver({
getPrincipal: () => undefined,
});
const req = new Request("https://example.com/u");
expect(await resolver.resolve(req)).toBeNull();
});
});

View File

@@ -0,0 +1,101 @@
import type { Principal } from "@suaveplan/api-types/principal";
import type { TenantContext, TenantId } from "@suaveplan/api-types/tenant";
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
import {
JwtTenantResolverError,
TENANT_RESOLVER_JWT_CODES,
} from "../error-codes/error-codes.js";
import {
type JwtTenantResolverOptions,
parseJwtTenantResolverOptions,
} from "../options/options.js";
/**
* Per-request principal extractor. The host attaches the resolved
* `Principal` (or `null`) to a side-channel keyed by `Request` — typically a
* `WeakMap` populated by the earlier `api-auth-*` chain step. The default
* extractor reads `(request as { principal?: Principal | null }).principal`
* so simple wiring (mutating the request once at session-resolution time)
* works out of the box; production hosts pass an explicit extractor.
*/
export type PrincipalExtractor = (
request: Request
) => Principal | null | undefined;
/**
* Full options object accepted by `createJwtTenantResolver`. Extends the
* Zod-validated `JwtTenantResolverOptions` with an explicit
* `getPrincipal` extractor so the per-request lookup is dependency-injected
* rather than baked into the resolver.
*/
export interface CreateJwtTenantResolverOptions
extends JwtTenantResolverOptions {
readonly getPrincipal?: PrincipalExtractor;
}
const defaultExtractor: PrincipalExtractor = (request) => {
const carrier = request as unknown as {
readonly principal?: Principal | null;
};
return carrier.principal ?? null;
};
function readClaim(principal: Principal, claimKey: string): TenantId | null {
if (claimKey === "tenantId") {
return principal.tenantId ?? null;
}
const value = principal.claims[claimKey];
if (typeof value === "string" && value.length > 0) {
return value as TenantId;
}
return null;
}
/**
* Build a Stage-4 `TenantResolver` that reads the tenant id from an
* already-resolved `Principal` attached by an earlier `api-auth-*` chain
* step. Returns a `TenantContext` when the configured claim is present;
* returns `null` (chain-advance sentinel) when absent. When
* `requirePrincipal: true`, a null/empty principal raises
* `JwtTenantResolverError(PRINCIPAL_MISSING_TENANT_ID)` instead of
* advancing.
*/
export function createJwtTenantResolver(
options?: CreateJwtTenantResolverOptions
): TenantResolver {
const { getPrincipal: extractor, ...zodInput } = options ?? {};
const resolved = parseJwtTenantResolverOptions(
Object.keys(zodInput).length === 0 ? undefined : zodInput
);
const getPrincipal = extractor ?? defaultExtractor;
return {
id: resolved.id,
async resolve(request: Request): Promise<TenantContext | null> {
const principal = getPrincipal(request) ?? null;
if (principal === null) {
if (resolved.requirePrincipal) {
throw new JwtTenantResolverError(
TENANT_RESOLVER_JWT_CODES.PRINCIPAL_MISSING_TENANT_ID,
"Tenant resolver requires an authenticated principal, but none was attached to the request"
);
}
return null;
}
const tenantId = readClaim(principal, resolved.claimKey);
if (tenantId === null) {
if (resolved.requirePrincipal) {
throw new JwtTenantResolverError(
TENANT_RESOLVER_JWT_CODES.PRINCIPAL_MISSING_TENANT_ID,
`Principal carries no '${resolved.claimKey}' claim and requirePrincipal is enabled`
);
}
return null;
}
return {
id: tenantId,
slug: tenantId,
};
},
};
}