Initial Commit
This commit is contained in:
73
packages/api-stack/api/api-tenant-resolver-jwt/README.md
Normal file
73
packages/api-stack/api/api-tenant-resolver-jwt/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# @suaveplan/api-tenant-resolver-jwt — tenant-resolver adapter for the Suaveplan API plugin system. Reads the already-resolved `Principal.tenantId` from the per-request context (attached by an earlier `api-auth-*` chain) and projects it into a `TenantContext`. When the configured claim is present the resolver short-circuits the chain; when absent it returns `null` so a header/subdomain resolver can take over. Configurable claim key (default `tenantId`) and strict-mode (`requirePrincipal: true`) that throws `TENANT_RESOLVER_JWT.PRINCIPAL_MISSING_TENANT_ID` instead of advancing.
|
||||
|
||||
Stage-4 tenant resolver for the Suaveplan API plugin system. Reads the
|
||||
already-resolved `Principal.tenantId` (attached by an earlier `api-auth-*`
|
||||
chain step) and projects it into a `TenantContext` for the host's per-request
|
||||
pipeline.
|
||||
|
||||
```ts
|
||||
import { createJwtTenantResolver } from "@suaveplan/api-tenant-resolver-jwt";
|
||||
|
||||
const resolver = createJwtTenantResolver();
|
||||
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-jwt`. Every public failure path in the resol…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-tenant-resolver-jwt`. Every public failure path in the resol…
|
||||
- [options](./src/options/options.md) — Defines and validates the `JwtTenantResolverOptions` accepted by `createJwtTenantResolver`. The options shape is delibe…
|
||||
- [options](./src/options/options.md) — Defines and validates the `JwtTenantResolverOptions` accepted by `createJwtTenantResolver`. The options shape is delibe…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements The tenant resolver factory `createJwtTenantResolver`. The resolver reads the tenant identifier from…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements The tenant resolver factory `createJwtTenantResolver`. The resolver reads the tenant identifier from…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why a dedicated package
|
||||
|
||||
The tenant-resolver chain (design doc §6, step 4) runs after the session
|
||||
resolver. When the session is a JWT carrying `tenant_id` / `tid`, the tenant
|
||||
resolver's job is trivial: read `principal.tenantId`. Collapsing that into a
|
||||
dedicated adapter — rather than encoding it inside `api-auth-jwt` — keeps the
|
||||
two contracts orthogonal and lets operators chain `tenant-resolver-jwt` with
|
||||
`api-tenant-resolver-header` and `api-tenant-resolver-subdomain` (Stage 8)
|
||||
for the paths a JWT alone doesn't cover.
|
||||
|
||||
## Chain interaction (forward reference)
|
||||
|
||||
The host runs resolvers left-to-right; the first non-`null` result wins:
|
||||
|
||||
```
|
||||
[ 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)
|
||||
```
|
||||
|
||||
When the JWT carries no tenant claim (or the request is unauthenticated on a
|
||||
public route), `tenant-resolver-jwt` returns `null` and the next resolver in
|
||||
the chain runs. With `requirePrincipal: true` the chain stops here and a
|
||||
401-equivalent is surfaced via `TENANT_RESOLVER_JWT.PRINCIPAL_MISSING_TENANT_ID`.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Default | Effect |
|
||||
| ------------------ | ------------- | ------------------------------------------------------------------ |
|
||||
| `id` | `"jwt"` | Resolver id used in chain logs and metric tags. |
|
||||
| `claimKey` | `"tenantId"` | Reads `Principal.tenantId` directly; any other key reads `claims`. |
|
||||
| `requirePrincipal` | `false` | Throw instead of returning `null` when claim is absent. |
|
||||
| `getPrincipal` | reads `req.principal` | Override how the principal is fetched from the request. |
|
||||
|
||||
## Error codes
|
||||
|
||||
- `TENANT_RESOLVER_JWT.PRINCIPAL_MISSING_TENANT_ID` — strict-mode rejected.
|
||||
- `TENANT_RESOLVER_JWT.INVALID_OPTIONS` — construction-time Zod failure.
|
||||
|
||||
## Exports
|
||||
|
||||
- `.` — top-level barrel re-exporting everything below.
|
||||
- `./resolver` — `createJwtTenantResolver`, `CreateJwtTenantResolverOptions`, `PrincipalExtractor`.
|
||||
- `./options` — `JwtTenantResolverOptions`, `parseJwtTenantResolverOptions`.
|
||||
- `./error-codes` — `JwtTenantResolverError`, `TENANT_RESOLVER_JWT_CODES`, `JwtTenantResolverErrorCode`.
|
||||
|
||||
75
packages/api-stack/api/api-tenant-resolver-jwt/package.json
Normal file
75
packages/api-stack/api/api-tenant-resolver-jwt/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@suaveplan/api-tenant-resolver-jwt",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-4 tenant-resolver adapter for the Suaveplan API plugin foundation. Reads the already-resolved `Principal.tenantId` from the per-request context (attached by an earlier `api-auth-*` chain) and projects it into a `TenantContext`. When the configured claim is present the resolver short-circuits the chain; when absent it returns `null` so a header/subdomain resolver can take over. Configurable claim key (default `tenantId`) and strict-mode (`requirePrincipal: true`) that throws `TENANT_RESOLVER_JWT.PRINCIPAL_MISSING_TENANT_ID` instead of advancing.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./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/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@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,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,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
JwtTenantResolverError,
|
||||
type JwtTenantResolverErrorCode,
|
||||
TENANT_RESOLVER_JWT_CODES,
|
||||
} from "./error-codes.js";
|
||||
14
packages/api-stack/api/api-tenant-resolver-jwt/src/index.ts
Normal file
14
packages/api-stack/api/api-tenant-resolver-jwt/src/index.ts
Normal 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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateJwtTenantResolverOptions,
|
||||
createJwtTenantResolver,
|
||||
type PrincipalExtractor,
|
||||
} from "./resolver.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Self-conformance for the `TenantResolver` contract: the jwt 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.
|
||||
*/
|
||||
import type {
|
||||
Principal,
|
||||
PrincipalId,
|
||||
Scope,
|
||||
} from "@suaveplan/api-types/principal";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type { TenantResolver } from "@suaveplan/api-types/tenant-resolver";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createJwtTenantResolver } from "../src/resolver/resolver.js";
|
||||
|
||||
function principal(tenantId: string | undefined): Principal {
|
||||
return {
|
||||
id: "u" as PrincipalId,
|
||||
scopes: [] as readonly Scope[],
|
||||
tenantId: tenantId as TenantId | undefined,
|
||||
claims: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(p: Principal | null): Request {
|
||||
const req = new Request(`https://example.com/${Math.random()}`);
|
||||
Object.defineProperty(req, "principal", { value: p, enumerable: true });
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("TenantResolver conformance — jwt", () => {
|
||||
const factory = (): TenantResolver => createJwtTenantResolver();
|
||||
|
||||
it("exposes a non-empty id", { timeout: 5000 }, () => {
|
||||
expect(factory().id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns a TenantContext when claim is present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = await factory().resolve(makeRequest(principal("t1")));
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.id).toBe("t1");
|
||||
expect(typeof ctx?.slug).toBe("string");
|
||||
});
|
||||
|
||||
it("returns null (chain advance) when claim is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
expect(
|
||||
await factory().resolve(makeRequest(principal(undefined)))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not mutate the inbound Request", { timeout: 5000 }, async () => {
|
||||
const req = makeRequest(principal("t2"));
|
||||
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(principal(`t${i}`)))
|
||||
)
|
||||
);
|
||||
expect(results).toHaveLength(32);
|
||||
for (const [i, ctx] of results.entries()) {
|
||||
expect(ctx?.id).toBe(`t${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-tenant-resolver-jwt/tsconfig.json
Normal file
12
packages/api-stack/api/api-tenant-resolver-jwt/tsconfig.json
Normal file
@@ -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,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-tenant-resolver-jwt",
|
||||
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\//, /^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