// 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( (val) => val !== null && typeof val === "object", "store must be a RateLimitStore object" ) .optional(), keyPrefix: z.string().min(1).optional(), }); const StoreSchema = z.custom( (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( (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 >;