Initial Commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type JwtTenantResolverOptions,
|
||||
parseJwtTenantResolverOptions,
|
||||
} from "./options.js";
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user