Initial Commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
type TenantPluginOptionsInput,
|
||||
type TenantPluginOptionsResolved,
|
||||
TenantPluginOptionsSchema,
|
||||
TenantStrategySpecSchema,
|
||||
} from "./options.js";
|
||||
export {
|
||||
type CreateTenantBody,
|
||||
CreateTenantBodySchema,
|
||||
DeleteTenantResponseSchema,
|
||||
type ListTenantsQueryParams,
|
||||
ListTenantsQuerySchema,
|
||||
type TenantIdParam,
|
||||
TenantIdParamSchema,
|
||||
type TenantListResponse,
|
||||
TenantListResponseSchema,
|
||||
type TenantResponse,
|
||||
TenantResponseSchema,
|
||||
type UpdateTenantBody,
|
||||
UpdateTenantBodySchema,
|
||||
} from "./routes.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# `options`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod schema validated at the `createTenantPlugin` boundary
|
||||
plus the strategy-spec discriminated-union schema each strategy is
|
||||
parsed against. Boundary validation catches configuration mistakes at
|
||||
plugin-construction time — long before a request hits the resolver
|
||||
chain — and surfaces them as `TenantPluginError(INVALID_INPUT)` with
|
||||
the original `ZodError` carried as `cause` for developer feedback.
|
||||
|
||||
## Features
|
||||
|
||||
- `TenantStrategySpecSchema` — discriminated union over the seven
|
||||
strategy variants. Catches typos in the `type` field, missing
|
||||
required parameters per variant, and empty strings.
|
||||
- `TenantPluginOptionsSchema` — full options object validated as a
|
||||
unit. Applies defaults for `required` (`false`), `allowSuspended`
|
||||
(`false`), and `routePrefix` (`"/tenants"`).
|
||||
- Custom `z.custom` validators for `store`, `logger`, and the optional
|
||||
rate-limit `store` so duck-typing accepts any conforming object
|
||||
without the schema needing to know the full implementation surface.
|
||||
- `routePrefix` regex enforces leading `/`, no trailing `/` (unless
|
||||
the prefix is exactly `/`), and a conservative character set so the
|
||||
resulting Hono routes are predictable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TenantPluginOptionsSchema } from "@suaveplan/api-plugin-tenant";
|
||||
|
||||
const parsed = TenantPluginOptionsSchema.parse({
|
||||
strategies: [{ type: "header", headerName: "x-tenant-id" }],
|
||||
});
|
||||
console.log(parsed.routePrefix); // "/tenants" (default)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TenantPluginOptionsSchema,
|
||||
TenantStrategySpecSchema,
|
||||
} from "@suaveplan/api-plugin-tenant";
|
||||
|
||||
const strategyResult = TenantStrategySpecSchema.safeParse({
|
||||
type: "cookie",
|
||||
cookieName: "tenant",
|
||||
});
|
||||
if (!strategyResult.success) {
|
||||
console.error(strategyResult.error.message);
|
||||
}
|
||||
|
||||
const optionsResult = TenantPluginOptionsSchema.safeParse({
|
||||
strategies: [],
|
||||
});
|
||||
console.assert(!optionsResult.success); // strategies cannot be empty
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TenantStrategySpecSchema: z.ZodDiscriminatedUnion` — accepts any of
|
||||
the seven strategy specs.
|
||||
- `TenantPluginOptionsSchema: z.ZodObject` — accepts the full options
|
||||
object; `parse` throws `ZodError`, `safeParse` returns a discriminated
|
||||
result.
|
||||
- `type TenantPluginOptionsInput = z.input<typeof TenantPluginOptionsSchema>` —
|
||||
pre-default-application input shape.
|
||||
- `type TenantPluginOptionsResolved = z.output<typeof TenantPluginOptionsSchema>` —
|
||||
post-default-application output shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `betterAuth`-style escape hatches present in
|
||||
`@suaveplan/api-plugin-auth`'s options schema are not needed here
|
||||
because the plugin owns its full surface. The `store`, `logger`, and
|
||||
`rateLimit.store` slots use `z.custom` rather than a full structural
|
||||
schema so the validator does not need to enumerate every method —
|
||||
duck-typing on the few methods callers will actually hit is enough.
|
||||
This keeps the schema small and the error messages targeted: if a
|
||||
caller passes a partial store missing `create`, the error names
|
||||
`getById/create/update/delete` and points to the call site.
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryTenantStore } from "../store/memory.js";
|
||||
import {
|
||||
TenantPluginOptionsSchema,
|
||||
TenantStrategySpecSchema,
|
||||
} from "./options.js";
|
||||
|
||||
describe("TenantStrategySpecSchema", () => {
|
||||
it("accepts every supported strategy variant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const spec of [
|
||||
{ type: "header", headerName: "x-tenant-id" },
|
||||
{ type: "subdomain", baseDomain: "app.example.com" },
|
||||
{ type: "domain" },
|
||||
{ type: "path", prefix: "/t/" },
|
||||
{ type: "jwt", claimName: "tenantId" },
|
||||
{ type: "query", paramName: "tenant" },
|
||||
{ type: "cookie", cookieName: "tenant" },
|
||||
]) {
|
||||
expect(TenantStrategySpecSchema.safeParse(spec).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown type", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
TenantStrategySpecSchema.safeParse({
|
||||
type: "telepathy",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty parameter values", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
TenantStrategySpecSchema.safeParse({
|
||||
type: "header",
|
||||
headerName: "",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a path prefix that does not start with '/'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
TenantStrategySpecSchema.safeParse({
|
||||
type: "path",
|
||||
prefix: "t/",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TenantPluginOptionsSchema", () => {
|
||||
it("applies defaults for routePrefix / required / allowSuspended", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = TenantPluginOptionsSchema.parse({
|
||||
strategies: [{ type: "header", headerName: "x-tenant-id" }],
|
||||
});
|
||||
expect(parsed.routePrefix).toBe("/tenants");
|
||||
expect(parsed.required).toBe(false);
|
||||
expect(parsed.allowSuspended).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a full options object", { timeout: 5000 }, () => {
|
||||
const parsed = TenantPluginOptionsSchema.parse({
|
||||
strategies: [{ type: "header", headerName: "x-tenant-id" }],
|
||||
cache: { ttlMs: 60_000, negativeTtlMs: 5_000, maxEntries: 100 },
|
||||
required: true,
|
||||
excludePaths: ["/health"],
|
||||
allowSuspended: true,
|
||||
routePrefix: "/admin/tenants",
|
||||
store: new MemoryTenantStore(),
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
rateLimit: {
|
||||
maxRequests: 100,
|
||||
windowMs: 60_000,
|
||||
keyPrefix: "rl",
|
||||
},
|
||||
});
|
||||
expect(parsed.required).toBe(true);
|
||||
expect(parsed.routePrefix).toBe("/admin/tenants");
|
||||
expect(parsed.cache?.ttlMs).toBe(60_000);
|
||||
expect(parsed.rateLimit?.keyPrefix).toBe("rl");
|
||||
});
|
||||
|
||||
it("rejects strategies = [] (must be non-empty)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
TenantPluginOptionsSchema.safeParse({ strategies: [] }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects routePrefix that does not match the regex", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
TenantPluginOptionsSchema.safeParse({
|
||||
strategies: [{ type: "header", headerName: "x" }],
|
||||
routePrefix: "tenants/", // missing leading /
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
TenantPluginOptionsSchema.safeParse({
|
||||
strategies: [{ type: "header", headerName: "x" }],
|
||||
routePrefix: "/tenants/", // trailing /
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
TenantPluginOptionsSchema.safeParse({
|
||||
strategies: [{ type: "header", headerName: "x" }],
|
||||
routePrefix: "/",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a store object missing required methods", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
TenantPluginOptionsSchema.safeParse({
|
||||
strategies: [{ type: "header", headerName: "x" }],
|
||||
store: { getById: () => undefined }, // missing create
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a logger that is not LoggerLike", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
TenantPluginOptionsSchema.safeParse({
|
||||
strategies: [{ type: "header", headerName: "x" }],
|
||||
logger: { info: "not a function" },
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a rate-limit store that is not an object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
TenantPluginOptionsSchema.safeParse({
|
||||
strategies: [{ type: "header", headerName: "x" }],
|
||||
rateLimit: { maxRequests: 1, windowMs: 1000, store: "bad" },
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
122
packages/api-stack/api/api-plugin-tenant/src/schemas/options.ts
Normal file
122
packages/api-stack/api/api-plugin-tenant/src/schemas/options.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// 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
|
||||
>;
|
||||
@@ -0,0 +1,99 @@
|
||||
# `routes`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod request/response schemas the five management routes
|
||||
mount when the plugin is configured with a `TenantStore`. Every body,
|
||||
query, and response shape flows through these schemas so the OpenAPI
|
||||
3.1 document published at `/openapi.json` is in lock-step with the
|
||||
runtime behaviour — operators and SDKs generated from the spec see the
|
||||
same surface the runtime enforces.
|
||||
|
||||
## Features
|
||||
|
||||
- `TenantResponseSchema` — wire-shape projection of the domain
|
||||
`Tenant` (Dates serialised as ISO strings).
|
||||
- `TenantListResponseSchema` — paginated list response (`items` +
|
||||
`total`).
|
||||
- `CreateTenantBodySchema` / `UpdateTenantBodySchema` — strict
|
||||
body schemas; unknown keys are rejected.
|
||||
- `TenantIdParamSchema` / `ListTenantsQuerySchema` — path-param and
|
||||
query schemas with coercion where appropriate (`limit`, `offset`
|
||||
are coerced from query string numerals).
|
||||
- Slug validation regex: lowercase alphanumeric + hyphen, starting with
|
||||
an alphanumeric. Keeps URLs predictable and prevents subtle
|
||||
case-folding bugs in path-prefix resolution.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CreateTenantBodySchema } from "@suaveplan/api-plugin-tenant";
|
||||
|
||||
const result = CreateTenantBodySchema.safeParse({
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
});
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CreateTenantBodySchema,
|
||||
ListTenantsQuerySchema,
|
||||
TenantResponseSchema,
|
||||
} from "@suaveplan/api-plugin-tenant";
|
||||
|
||||
// Query coercion — limit/offset arrive as strings from the URL.
|
||||
const query = ListTenantsQuerySchema.parse({
|
||||
status: "active",
|
||||
limit: "50",
|
||||
offset: "0",
|
||||
});
|
||||
console.log(query.limit); // number, not string
|
||||
|
||||
// Round-trip a Tenant through the response schema.
|
||||
const wire = TenantResponseSchema.parse({
|
||||
id: "tenant_1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
settings: {},
|
||||
enabledPlugins: ["billing"],
|
||||
domains: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Reject invalid slug at the boundary.
|
||||
const bad = CreateTenantBodySchema.safeParse({
|
||||
name: "X",
|
||||
slug: "ACME!", // uppercase + punctuation
|
||||
});
|
||||
console.assert(!bad.success);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TenantResponseSchema` — single-tenant wire shape.
|
||||
- `TenantListResponseSchema` — `{ items: TenantResponse[]; total: number }`.
|
||||
- `CreateTenantBodySchema` — strict object; `name`, `slug` required.
|
||||
- `UpdateTenantBodySchema` — strict object; all fields optional.
|
||||
- `TenantIdParamSchema` — `{ id: string }`.
|
||||
- `ListTenantsQuerySchema` — `{ status?, limit?, offset? }` with
|
||||
numeric coercion on `limit` and `offset`.
|
||||
- `DeleteTenantResponseSchema` — `{ success: true }`.
|
||||
- Type aliases (`CreateTenantBody`, `UpdateTenantBody`, etc.) are
|
||||
exported for handler signatures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`strict()` is applied to `CreateTenantBodySchema`, `UpdateTenantBodySchema`,
|
||||
and the nested settings shape so unknown keys are rejected instead of
|
||||
silently dropped. This matters because the same shape round-trips
|
||||
through OpenAPI codegen on the client side — unknown keys on a strict
|
||||
schema imply the client has drifted, and the right behaviour is to fail
|
||||
loudly. The slug regex is intentionally conservative so URLs survive
|
||||
percent-encoding, CDN normalisation, and case-insensitive DNS lookups
|
||||
without ambiguity.
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CreateTenantBodySchema,
|
||||
DeleteTenantResponseSchema,
|
||||
ListTenantsQuerySchema,
|
||||
TenantIdParamSchema,
|
||||
TenantListResponseSchema,
|
||||
TenantResponseSchema,
|
||||
UpdateTenantBodySchema,
|
||||
} from "./routes.js";
|
||||
|
||||
describe("CRUD route schemas", () => {
|
||||
it("CreateTenantBodySchema accepts valid bodies", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
CreateTenantBodySchema.safeParse({
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
CreateTenantBodySchema.safeParse({
|
||||
name: "Acme",
|
||||
slug: "acme-2",
|
||||
status: "active",
|
||||
plan: "pro",
|
||||
enabledPlugins: ["billing"],
|
||||
domains: ["acme.example.com"],
|
||||
settings: {
|
||||
locale: "en",
|
||||
features: { x: true },
|
||||
limits: { maxUsers: 10 },
|
||||
},
|
||||
metadata: { tier: "gold" },
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("CreateTenantBodySchema rejects invalid slug", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
CreateTenantBodySchema.safeParse({
|
||||
name: "X",
|
||||
slug: "ACME!",
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
CreateTenantBodySchema.safeParse({
|
||||
name: "X",
|
||||
slug: "",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("CreateTenantBodySchema rejects unknown keys (strict)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
CreateTenantBodySchema.safeParse({
|
||||
name: "X",
|
||||
slug: "x",
|
||||
bogus: true,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("UpdateTenantBodySchema accepts an empty patch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(UpdateTenantBodySchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("UpdateTenantBodySchema rejects invalid slug rename", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(UpdateTenantBodySchema.safeParse({ slug: "BAD!" }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("TenantIdParamSchema requires non-empty id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(TenantIdParamSchema.safeParse({ id: "x" }).success).toBe(true);
|
||||
expect(TenantIdParamSchema.safeParse({ id: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("ListTenantsQuerySchema coerces numeric strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = ListTenantsQuerySchema.parse({
|
||||
status: "active",
|
||||
limit: "50",
|
||||
offset: "0",
|
||||
});
|
||||
expect(parsed.limit).toBe(50);
|
||||
expect(parsed.offset).toBe(0);
|
||||
expect(parsed.status).toBe("active");
|
||||
});
|
||||
|
||||
it("ListTenantsQuerySchema rejects limit > 500", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
ListTenantsQuerySchema.safeParse({ limit: "1000" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("TenantResponseSchema round-trips a tenant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = TenantResponseSchema.parse({
|
||||
id: "tenant_1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
settings: {},
|
||||
enabledPlugins: [],
|
||||
domains: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
expect(wire.id).toBe("tenant_1");
|
||||
});
|
||||
|
||||
it("TenantListResponseSchema accepts items + total", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = TenantListResponseSchema.parse({
|
||||
items: [],
|
||||
total: 0,
|
||||
});
|
||||
expect(wire.total).toBe(0);
|
||||
});
|
||||
|
||||
it("DeleteTenantResponseSchema accepts only { success: true }", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
DeleteTenantResponseSchema.safeParse({ success: true }).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
DeleteTenantResponseSchema.safeParse({ success: false }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
127
packages/api-stack/api/api-plugin-tenant/src/schemas/routes.ts
Normal file
127
packages/api-stack/api/api-plugin-tenant/src/schemas/routes.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// schema: boundary-only — these schemas are consumer-facing surfaces
|
||||
// exposed via the `./schemas/routes` subpath so SDKs can derive types
|
||||
// from them. The plugin uses them internally only as the source of
|
||||
// truth for the OpenAPI route definitions (see `routes/routes.ts`).
|
||||
import z from "zod";
|
||||
|
||||
/** Tenant status string union — mirrors `TenantStatus` from api-tenant. */
|
||||
const TenantStatusSchema = z.enum([
|
||||
"active",
|
||||
"suspended",
|
||||
"pending",
|
||||
"deleted",
|
||||
]);
|
||||
|
||||
const TenantLimitsSchema = z
|
||||
.object({
|
||||
maxUsers: z.number().int().nonnegative().optional(),
|
||||
maxStorage: z.number().int().nonnegative().optional(),
|
||||
maxApiCalls: z.number().int().nonnegative().optional(),
|
||||
maxSites: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const TenantBrandingSchema = z
|
||||
.object({
|
||||
logo: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
secondaryColor: z.string().optional(),
|
||||
customCss: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const TenantSettingsSchema = z
|
||||
.object({
|
||||
locale: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
dateFormat: z.string().optional(),
|
||||
features: z.record(z.string(), z.boolean()).optional(),
|
||||
limits: TenantLimitsSchema.optional(),
|
||||
branding: TenantBrandingSchema.optional(),
|
||||
custom: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Wire shape returned by GET routes. */
|
||||
export const TenantResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
status: TenantStatusSchema,
|
||||
plan: z.string().optional(),
|
||||
settings: TenantSettingsSchema,
|
||||
enabledPlugins: z.array(z.string()),
|
||||
domains: z.array(z.string()),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const TenantListResponseSchema = z.object({
|
||||
items: z.array(TenantResponseSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/** Body accepted by `POST /tenants`. */
|
||||
export const CreateTenantBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(200),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9][a-z0-9-]*$/u, {
|
||||
message:
|
||||
"slug must be lowercase alphanumeric / hyphen and start with alphanumeric",
|
||||
}),
|
||||
status: TenantStatusSchema.optional(),
|
||||
plan: z.string().optional(),
|
||||
enabledPlugins: z.array(z.string()).optional(),
|
||||
domains: z.array(z.string()).optional(),
|
||||
settings: TenantSettingsSchema.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Body accepted by `PATCH /tenants/:id`. */
|
||||
export const UpdateTenantBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9][a-z0-9-]*$/u)
|
||||
.optional(),
|
||||
status: TenantStatusSchema.optional(),
|
||||
plan: z.string().optional(),
|
||||
enabledPlugins: z.array(z.string()).optional(),
|
||||
domains: z.array(z.string()).optional(),
|
||||
settings: TenantSettingsSchema.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Path param schema for the `:id` routes. */
|
||||
export const TenantIdParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Query schema for `GET /tenants`. */
|
||||
export const ListTenantsQuerySchema = z.object({
|
||||
status: TenantStatusSchema.optional(),
|
||||
limit: z.coerce.number().int().positive().max(500).optional(),
|
||||
offset: z.coerce.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
/** Empty response shape for `DELETE`. */
|
||||
export const DeleteTenantResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
});
|
||||
|
||||
export type CreateTenantBody = z.infer<typeof CreateTenantBodySchema>;
|
||||
export type UpdateTenantBody = z.infer<typeof UpdateTenantBodySchema>;
|
||||
export type TenantIdParam = z.infer<typeof TenantIdParamSchema>;
|
||||
export type ListTenantsQueryParams = z.infer<typeof ListTenantsQuerySchema>;
|
||||
export type TenantResponse = z.infer<typeof TenantResponseSchema>;
|
||||
export type TenantListResponse = z.infer<typeof TenantListResponseSchema>;
|
||||
Reference in New Issue
Block a user