Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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";

View File

@@ -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.

View File

@@ -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);
});
});

View 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
>;

View File

@@ -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.

View File

@@ -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);
});
});

View 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>;