Files
suaveplan/packages/api-stack/api/api-plugin-tenant/src/schemas/options.ts
2026-05-27 23:29:53 +01:00

123 lines
3.8 KiB
TypeScript

// schema: boundary-only — TenantStrategySpecSchema is exposed for
// consumers building stack-specific factories on top of the plugin's
// option shape. The plugin itself parses options via
// TenantPluginOptionsSchema (which embeds the strategy schema), so
// `TenantStrategySpecSchema.parse(...)` does not appear at the plugin's
// own call sites.
import z from "zod";
/**
* Strategy spec schemas — one per resolver type. Composed into a Zod
* discriminated union so the plugin can route each spec to the right
* concrete `TenantResolver` implementation.
*/
const HeaderStrategySchema = z.object({
type: z.literal("header"),
headerName: z.string().min(1),
});
const SubdomainStrategySchema = z.object({
type: z.literal("subdomain"),
baseDomain: z.string().min(1),
});
const DomainStrategySchema = z.object({ type: z.literal("domain") });
const PathStrategySchema = z.object({
type: z.literal("path"),
prefix: z.string().regex(/^\//u, "prefix must start with '/'"),
});
const JWTStrategySchema = z.object({
type: z.literal("jwt"),
claimName: z.string().min(1),
});
const QueryStrategySchema = z.object({
type: z.literal("query"),
paramName: z.string().min(1),
});
const CookieStrategySchema = z.object({
type: z.literal("cookie"),
cookieName: z.string().min(1),
});
export const TenantStrategySpecSchema = z.discriminatedUnion("type", [
HeaderStrategySchema,
SubdomainStrategySchema,
DomainStrategySchema,
PathStrategySchema,
JWTStrategySchema,
QueryStrategySchema,
CookieStrategySchema,
]);
const CacheSchema = z.object({
ttlMs: z.number().int().positive(),
negativeTtlMs: z.number().int().positive().optional(),
maxEntries: z.number().int().positive().optional(),
});
const RateLimitSchema = z.object({
maxRequests: z.number().int().positive(),
windowMs: z.number().int().positive(),
store: z
.custom<unknown>(
(val) => val !== null && typeof val === "object",
"store must be a RateLimitStore object"
)
.optional(),
keyPrefix: z.string().min(1).optional(),
});
const StoreSchema = z.custom<unknown>(
(val) =>
val !== null &&
typeof val === "object" &&
typeof (val as { getById?: unknown }).getById === "function" &&
typeof (val as { create?: unknown }).create === "function",
"store must implement the TenantStore interface (getById/create/update/delete)"
);
const LoggerSchema = z.custom<unknown>(
(val) =>
val !== null &&
typeof val === "object" &&
typeof (val as { info?: unknown }).info === "function",
"logger must be a LoggerLike with debug/info/warn/error"
);
/**
* Zod schema for `TenantPluginOptions`. Validated by `createTenantPlugin`
* at the factory boundary — invalid input throws
* `TenantPluginError(INVALID_INPUT)` carrying the `ZodError` as `cause`.
*
* `routePrefix` MUST start with `/` and MUST NOT end with `/` (unless it
* is exactly `/`).
*/
export const TenantPluginOptionsSchema = z.object({
strategies: z.array(TenantStrategySpecSchema).min(1),
cache: CacheSchema.optional(),
required: z.boolean().default(false),
excludePaths: z.array(z.string()).optional(),
allowSuspended: z.boolean().default(false),
routePrefix: z
.string()
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
message:
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
})
.default("/tenants"),
store: StoreSchema.optional(),
logger: LoggerSchema.optional(),
rateLimit: RateLimitSchema.optional(),
});
export type TenantPluginOptionsInput = z.input<
typeof TenantPluginOptionsSchema
>;
export type TenantPluginOptionsResolved = z.output<
typeof TenantPluginOptionsSchema
>;