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,101 @@
# @suaveplan/api-plugin-site - Multi-site / multi-domain management plugin for @suaveplan/api. Stores `site` records with name / slug / theme / settings / push config, resolves Host headers to a site through DomainResolverService, exposes public + private OpenAPI route trees, ships an injectable domain-resolver Hono middleware, emits site:settings:updated events, optionally auto-creates a default site on organization:created. 14 SITE.* error codes, 5 OTel instruments, owner-scoped queries, server-tier.
Multi-site / multi-domain management plugin for `@suaveplan/api`. Stores `site` records (name, slug, theme, settings, push config) in SurrealDB, resolves incoming `Host` headers to a site via `DomainResolverService`, exposes public + private OpenAPI Hono route trees, and emits the `site:settings:updated` event whenever a site's settings change so downstream caches / CDNs can react.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `SITE.*` error-code registry and the `SiteError` class used by every public API path in `@suaveplan/api…
- [error-codes](./src/error-codes/error-codes.md) — Owns the stable `SITE.*` error-code registry and the `SiteError` class used by every public API path in `@suaveplan/api…
- [integration](./src/integration/middleware-recommendations.md) — `MIDDLEWARE_RECOMMENDATIONS` is a frozen, typed bundle of recommended rate-limit and audit configuration for each docum…
- [middleware-recommendations](./src/integration/middleware-recommendations.md) — `MIDDLEWARE_RECOMMENDATIONS` is a frozen, typed bundle of recommended rate-limit and audit configuration for each docum…
- [middleware](./src/middleware/domain-resolver.md) — Factory that builds a Hono middleware which resolves the incoming request's `Host` header to a `site` + `organization`…
- [domain-resolver](./src/middleware/domain-resolver.md) — Factory that builds a Hono middleware which resolves the incoming request's `Host` header to a `site` + `organization`…
- [otel](./src/otel/instruments.md) — Owns the lazy-built OpenTelemetry instrument bundle (`SiteInstruments`) that every observable code path in `@suaveplan/…
- [instruments](./src/otel/instruments.md) — Owns the lazy-built OpenTelemetry instrument bundle (`SiteInstruments`) that every observable code path in `@suaveplan/…
- [plugin](./src/plugin/plugin.md) — Owns `createSitePlugin`, the canonical factory that produces the `PluginDescriptor` consumed by `@suaveplan/api-plugin`…
- [plugin](./src/plugin/plugin.md) — Owns `createSitePlugin`, the canonical factory that produces the `PluginDescriptor` consumed by `@suaveplan/api-plugin`…
- [private-routes](./src/routes/private-routes.md) — Builds the authenticated Hono route tree for site CRUD and domain management. The factory is intentionally auth-agnosti…
- [public-routes](./src/routes/public-routes.md) — Builds the public (no-auth) Hono route tree that the plugin mounts under the configured `routePrefix`. The two endpoint…
- [domain](./src/schemas/domain.md) — Zod schemas for every domain-shaped value: the validated domain literal itself, the request/response shapes for `addDom…
- [options](./src/schemas/options.md) — Owns the plugin-options schema and the `mergeConfig` / `DEFAULT_CONFIG` helpers used by `createSitePlugin(...)`. Every…
- [push](./src/schemas/push.md) — Schemas and a pure validator for the per-site push-notification configuration. The site plugin treats push config as op…
- [site](./src/schemas/site.md) — Defines the Zod schemas that describe the canonical `site` entity, its create/update inputs, the public-facing slice, a…
- [db-port](./src/service/db-port.md) — Declares `SiteDb`, the structural database port that `SiteService` and `DomainResolverService` consume. suaveplan's found…
- [domain-resolver](./src/service/domain-resolver.md) — `DomainResolverService` maps a request's `Host` (or arbitrary domain string) to a persisted `site` record and the ownin…
- [normalize-domain](./src/service/normalize-domain.md) — Pure helper that mirrors the SurrealDB `fn::site::domain::normalize` function in TypeScript so application-tier code (t…
- [site](./src/service/site.md) — `SiteService` is the application-tier owner of every CRUD path against the SurrealDB `site` table. It mediates between…
- [services](./src/services/registry-augmentation.md) — Augments the `ServiceRegistry` interface from `@suaveplan/api-plugin` so the three slots the site plugin owns — `siteSe…
- [registry-augmentation](./src/services/registry-augmentation.md) — Augments the `ServiceRegistry` interface from `@suaveplan/api-plugin` so the three slots the site plugin owns — `siteSe…
- [types](./src/types/types.md) — Runtime-free type module that defines every shared shape used by the site plugin: persisted `Site` / `SiteSettings` / `…
- [types](./src/types/types.md) — Runtime-free type module that defines every shared shape used by the site plugin: persisted `Site` / `SiteSettings` / `…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-site
```
## Quick start
```ts
import { createPluginRegistry } from "@suaveplan/api-plugin";
import { createDatabasePlugin } from "@suaveplan/api-plugin-database";
import { createSitePlugin } from "@suaveplan/api-plugin-site";
const registry = createPluginRegistry();
registry.register(createDatabasePlugin({ /* surrealdb config */ }));
registry.register(createSitePlugin({
maxSitesPerOrg: 10,
maxDomainsPerSite: 5,
autoCreateDefaultSite: true,
defaultDomainPattern: "{slug}.suaveplan.app",
}));
await registry.start();
```
After `registry.start()`, the site plugin exposes three services on `ServiceRegistry`:
- `siteService: SiteService` — CRUD + settings + domain management.
- `domainResolver: DomainResolverService` — `resolve(domain)` returning `{success, site, organization}`.
- `domainResolverMiddleware: Hono Middleware` — request-scoped resolver that injects `site` / `organization` / `siteId` / `organizationId` into `c`.
## What it does
- Persists `site` records under SurrealDB with per-table SurQL migration shipped in `surql/tables/site.surql`.
- Enforces per-organization and per-site caps (`maxSitesPerOrg`, `maxDomainsPerSite`).
- Public route: `GET /sites/resolve/{domain}` — domain → site/org lookup.
- Public route: `GET /sites/{id}/public` — sanitised `SitePublicInfo` projection (no push secrets).
- Private routes: `POST /sites`, `PATCH /sites/{id}`, `POST /sites/{id}/domains`, `DELETE /sites/{id}/domains`.
- Optional `organization:created` listener that auto-creates a default site per new organisation, with `site:bootstrap:failed` events on failure.
- 14 stable `SITE.*` error codes raised through `SiteError extends SuaveplanError`.
- OTel instruments: `site.sites_created_total{outcome}`, `site.sites_updated_total{kind}`, `site.domain_resolutions_total{outcome}`, `site.domain_resolution_duration_ms`, `site.bootstrap_failed_total`.
## Modules
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `SITE_CODES` + `SiteError`.
- [`src/otel/instruments.md`](./src/otel/instruments.md) — OTel instrument bundle.
- [`src/schemas/site.md`](./src/schemas/site.md) — `SiteSchema`, `CreateSiteSchema`, `UpdateSiteSchema`, `SitePublicInfoSchema`.
- [`src/schemas/domain.md`](./src/schemas/domain.md) — `DomainSchema`, resolve / add / remove request+response.
- [`src/schemas/push.md`](./src/schemas/push.md) — `ApplePushConfigSchema`, `FirebasePushConfigSchema`, `validatePushConfig`.
- [`src/schemas/options.md`](./src/schemas/options.md) — `SitePluginOptionsSchema`, `mergeConfig`, `DEFAULT_CONFIG`.
- [`src/service/site.md`](./src/service/site.md) — `SiteService`.
- [`src/service/domain-resolver.md`](./src/service/domain-resolver.md) — `DomainResolverService`.
- [`src/service/db-port.md`](./src/service/db-port.md) — `SiteDb` structural port.
- [`src/service/normalize-domain.md`](./src/service/normalize-domain.md) — domain canonicalisation.
- [`src/middleware/domain-resolver.md`](./src/middleware/domain-resolver.md) — Hono middleware factory.
- [`src/routes/public-routes.md`](./src/routes/public-routes.md) — public OpenAPI router.
- [`src/routes/private-routes.md`](./src/routes/private-routes.md) — private OpenAPI router (auth supplied by caller).
- [`src/plugin/plugin.md`](./src/plugin/plugin.md) — `createSitePlugin` factory.
- [`src/services/registry-augmentation.md`](./src/services/registry-augmentation.md) — `ServiceRegistry` module augmentation.
- [`src/integration/middleware-recommendations.md`](./src/integration/middleware-recommendations.md) — `MIDDLEWARE_RECOMMENDATIONS` constant + types.
## Middleware composition
The site plugin ships **zero runtime dependencies** on `@suaveplan/api-plugin-rate-limit` and `@suaveplan/api-plugin-audit`. Composition stays in the host application via Hono's `app.use(prefix, middleware)` pattern.
- See [`docs/middleware-integration.md`](./docs/middleware-integration.md) for the canonical end-to-end wiring example and the recommended layering order (audit OUTERMOST → rate-limit MIDDLE → site routes INNERMOST).
- See [`src/integration/middleware-recommendations.md`](./src/integration/middleware-recommendations.md) for the `MIDDLEWARE_RECOMMENDATIONS` constant exposing recommended rate-limit windows and audit categories per route group.
- The pattern is proven by [`src/integration/middleware-composition.integration.test.ts`](./src/integration/middleware-composition.integration.test.ts) — a runnable composition asserting site behaviour, rate-limit headers, and audit event emission.

View File

@@ -0,0 +1,112 @@
# Middleware composition — site + rate-limit + audit
`@suaveplan/api-plugin-site` ships **zero runtime dependencies** on `@suaveplan/api-plugin-rate-limit` and `@suaveplan/api-plugin-audit`. This is deliberate. The composition knob lives in the host application: callers wire the middleware via Hono's `app.use(prefix, middleware)` pattern so they can swap or skip implementations as their deployment requires.
This guide documents the canonical pattern, the recommended layering order, and a copy-pasteable end-to-end example. A runnable verification of every assertion lives in `src/integration/middleware-composition.integration.test.ts`.
## Layering order
```
┌────────────────────────────────────────┐
inbound │ audit middleware (OUTERMOST) │
request ──▶ │ ──▶ rate-limit middleware │
│ ──▶ site routes (INNERMOST) │
└────────────────────────────────────────┘
```
The order matters and is **not interchangeable**:
1. **Audit OUTERMOST.** The audit middleware records one event per inbound request — including requests that are rejected by rate-limit with `429`. Putting audit inside rate-limit would silently drop the audit record on every throttled call, defeating the purpose of an immutable trail. The audit middleware is also a no-throw boundary by contract, so it adds no failure modes.
2. **Rate-limit MIDDLE.** The rate-limit middleware is mounted on the site prefix (`/sites/*`) so it does not consume budget for unrelated routes mounted on the same host (health checks, OpenAPI documents, etc). Putting rate-limit inside the site routes would force every site route to opt in by hand and would miss requests that hit `404` route stubs.
3. **Site routes INNERMOST.** The site plugin's `createPrivateSiteRoutes` and `createPublicSiteRoutes` factories return `OpenAPIHono` instances that are mounted via `app.route(prefix, routes)`. They make zero assumptions about the surrounding middleware stack.
## Canonical example
```ts
import {
AuditLogger,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import { createAuditMiddleware } from "@suaveplan/api-plugin-audit";
import {
createDefaultStorage,
createRateLimitService,
rateLimit,
RateLimitPluginOptionsSchema,
} from "@suaveplan/api-plugin-rate-limit";
import {
createPrivateSiteRoutes,
createPublicSiteRoutes,
MIDDLEWARE_RECOMMENDATIONS,
SiteService,
} from "@suaveplan/api-plugin-site";
import { Hono } from "hono";
// ── 1. Site service + routes ────────────────────────────────────────
const siteService = new SiteService(db, mergeConfig());
const privateRoutes = createPrivateSiteRoutes({ siteService });
const publicRoutes = createPublicSiteRoutes({ domainResolver });
// ── 2. Rate-limit service driven by the recommendations bundle ─────
const clock = () => Date.now();
const rateLimitConfig = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: MIDDLEWARE_RECOMMENDATIONS.map((r) => ({
id: r.routeGroup,
path: r.rateLimit.pathPattern,
limit: r.rateLimit.limit,
windowMs: r.rateLimit.windowMs,
})),
});
const rateLimitService = createRateLimitService({
config: rateLimitConfig,
storage: createDefaultStorage({ clock }),
clock,
logger: hostLogger,
});
// ── 3. Audit logger backed by your AuditStorageAdapter ─────────────
const auditSink = new MemoryAuditAdapter();
const auditLogger = new AuditLogger({
storage: auditSink,
secretKey: process.env.AUDIT_SECRET ?? "x".repeat(32),
enableChainIntegrity: true,
});
const auditMiddleware = createAuditMiddleware({
logger: auditLogger,
config: {
sink: auditSink,
secretKey: process.env.AUDIT_SECRET ?? "x".repeat(32),
samplingRate: 1,
excludePaths: ["/health"],
redactQueryParams: ["token"],
redactHeaders: ["authorization", "cookie"],
redactBodyFields: ["password", "secret"],
recordResponseSize: true,
recordRequestBody: false,
enableChainIntegrity: true,
rng: Math.random,
},
hostLogger,
});
// ── 4. Compose: audit outermost → rate-limit → site routes ────────
const app = new Hono();
app.use("*", auditMiddleware);
app.use("/sites/*", rateLimit({ service: rateLimitService }));
app.route("/", publicRoutes);
app.route("/", privateRoutes);
```
## Recommendations
The `MIDDLEWARE_RECOMMENDATIONS` constant exported from this package describes the recommended rate-limit ceilings and audit categories per documented route group (`public:resolve`, `public:read`, `private:write`). See `src/integration/middleware-recommendations.md` for the per-field rationale and an advanced consumption example.
## Verification
The integration test at `src/integration/middleware-composition.integration.test.ts` boots a real Hono app composed exactly as documented above, exercises `POST /sites`, and asserts:
- The response status is `201` and the site row is persisted (site behaviour preserved).
- `X-RateLimit-Limit` is present on the response (rate-limit observed the request).
- The audit sink contains exactly one `AuditEvent` with `details.method === "POST"` and `details.path === "/sites"`.

View File

@@ -0,0 +1,152 @@
{
"name": "@suaveplan/api-plugin-site",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Multi-site / multi-domain management plugin for @suaveplan/api. Stores `site` records with name / slug / theme / settings / push config, resolves Host headers to a site through DomainResolverService, exposes public + private OpenAPI route trees, ships an injectable domain-resolver Hono middleware, emits site:settings:updated events, optionally auto-creates a default site on organization:created. 14 SITE.* error codes, 5 OTel instruments, owner-scoped queries, server-tier.",
"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"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./schemas/site": {
"import": {
"types": "./dist/schemas/site.d.ts",
"default": "./dist/schemas/site.js"
}
},
"./schemas/domain": {
"import": {
"types": "./dist/schemas/domain.d.ts",
"default": "./dist/schemas/domain.js"
}
},
"./schemas/push": {
"import": {
"types": "./dist/schemas/push.d.ts",
"default": "./dist/schemas/push.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./service/site": {
"import": {
"types": "./dist/service/site.d.ts",
"default": "./dist/service/site.js"
}
},
"./service/domain-resolver": {
"import": {
"types": "./dist/service/domain-resolver.d.ts",
"default": "./dist/service/domain-resolver.js"
}
},
"./middleware/domain-resolver": {
"import": {
"types": "./dist/middleware/domain-resolver.d.ts",
"default": "./dist/middleware/domain-resolver.js"
}
},
"./routes/public-routes": {
"import": {
"types": "./dist/routes/public-routes.d.ts",
"default": "./dist/routes/public-routes.js"
}
},
"./routes/private-routes": {
"import": {
"types": "./dist/routes/private-routes.d.ts",
"default": "./dist/routes/private-routes.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
},
"./integration/middleware-recommendations": {
"import": {
"types": "./dist/integration/middleware-recommendations.d.ts",
"default": "./dist/integration/middleware-recommendations.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": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-database": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/api-plugin-audit": "workspace:^",
"@suaveplan/api-plugin-rate-limit": "workspace:^",
"@suaveplan/audit-logging": "workspace:^",
"@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", "surql", "docs", "README.md"]
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createDomainResolverMiddleware,
createPrivateSiteRoutes,
createPublicSiteRoutes,
createSitePlugin,
DEFAULT_CONFIG,
DomainResolverService,
DomainSchema,
getSiteInstruments,
MIDDLEWARE_RECOMMENDATIONS,
mergeConfig,
normalizeDomain,
resetSiteInstruments,
SITE_CODES,
SITE_STATE_ACTIVE,
SITE_STATE_ARCHIVED,
SITE_STATE_MAINTENANCE,
SITE_STATE_SUSPENDED,
SiteError,
SiteSchema,
SiteService,
validatePushConfig,
} from "./index.js";
describe("public surface", () => {
it("re-exports every documented symbol", { timeout: 5000 }, () => {
const surface = {
SITE_CODES,
SiteError,
DEFAULT_CONFIG,
mergeConfig,
SiteService,
DomainResolverService,
normalizeDomain,
createDomainResolverMiddleware,
createPublicSiteRoutes,
createPrivateSiteRoutes,
createSitePlugin,
getSiteInstruments,
resetSiteInstruments,
validatePushConfig,
SiteSchema,
DomainSchema,
SITE_STATE_ACTIVE,
SITE_STATE_MAINTENANCE,
SITE_STATE_SUSPENDED,
SITE_STATE_ARCHIVED,
MIDDLEWARE_RECOMMENDATIONS,
};
expect(Object.keys(surface).length).toBe(21);
});
});

View File

@@ -0,0 +1,61 @@
# `error-codes/error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the stable `SITE.*` error-code registry and the `SiteError` class used by every public API path in `@suaveplan/api-plugin-site`. Each code is a stable string that consumers can pattern-match without relying on message text — important for cross-version compatibility, log aggregation, alert routing, and HTTP-status mapping. Centralising the codes in one file makes the contract auditable in a single grep and prevents new code paths from inventing ad-hoc strings.
The registry is built with `defineCodes("SITE", [...])` from `@suaveplan/error`, which freezes the array into a readonly object whose values are namespaced `"SITE.<KEY>"`. `SiteError extends SuaveplanError<SiteErrorCode>` so `instanceof SiteError` narrows `error.code` to the literal-union `SiteErrorCode`.
## Features
- 14 canonical codes covering input validation, lifecycle wiring, site CRUD, domain CRUD, resolution failures, and bootstrap failures.
- `SiteError` constructor accepts `SuaveplanErrorOptions` so callers can attach `cause`, `context`, and structured fields.
- Stable string codes — `error.code === SITE_CODES.SITE_NOT_FOUND` survives minification and i18n.
- Per-code doc comment naming the HTTP status the route layer maps to.
- Zero runtime dependencies beyond `@suaveplan/error`.
## Usage: Basic
```ts
import { SITE_CODES, SiteError } from "@suaveplan/api-plugin-site/error-codes/error-codes";
if (site === null) {
throw new SiteError(SITE_CODES.SITE_NOT_FOUND, `site '${id}' not found`);
}
```
## Usage: Advanced
```ts
import { SITE_CODES, SiteError } from "@suaveplan/api-plugin-site/error-codes/error-codes";
try {
await db.query("SELECT * FROM site WHERE id = $p0", [id]);
} catch (cause) {
throw new SiteError(SITE_CODES.STORAGE_ERROR, "SurrealDB query failed", {
cause: cause instanceof Error ? cause : undefined,
context: { statusCode: 502, id },
});
}
// Discrimination at a boundary:
if (err instanceof SiteError && err.code === SITE_CODES.DOMAIN_ALREADY_CLAIMED) {
return c.json({ error: err.message }, 409);
}
```
## API Reference
- `SITE_CODES` — readonly object of 14 keys mapped to namespaced `"SITE.<KEY>"` literals.
- `SiteErrorCode` — string-literal union of every value in `SITE_CODES`.
- `class SiteError extends SuaveplanError<SiteErrorCode>` — constructor `(code, message, options?)`. Exposes `siteCode` as a typed alias of `code`.
## Implementation Notes
- The registry is the contract: deleting a code to satisfy a coverage gate is forbidden by `AGENTS.md §2c`. Every code MUST be thrown by at least one production path, asserted by `error-codes.test.ts`.
- HTTP statuses are documented per-code but the mapping is performed by the route layer (`routes/`) via `error.context.statusCode` — the registry itself is transport-agnostic.
- New codes require a registry edit, an OpenSpec change, and a test that drives the new throw path. There are no silent additions.

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SITE_CODES, SiteError, type SiteErrorCode } from "./error-codes.js";
describe("SITE error codes registry", () => {
it("declares all 14 canonical SITE.* codes", { timeout: 5000 }, () => {
const expected: SiteErrorCode[] = [
"SITE.INVALID_OPTIONS",
"SITE.SERVICE_NOT_AVAILABLE",
"SITE.SITE_NOT_FOUND",
"SITE.SITE_LIMIT_EXCEEDED",
"SITE.DOMAIN_LIMIT_EXCEEDED",
"SITE.DOMAIN_ALREADY_CLAIMED",
"SITE.LAST_DOMAIN_REMOVAL",
"SITE.DOMAIN_NOT_FOUND",
"SITE.INVALID_DOMAIN",
"SITE.VALIDATION",
"SITE.BOOTSTRAP_FAILED",
"SITE.STORAGE_ERROR",
"SITE.ROUTE_PREFIX_COLLISION",
"SITE.RESOLUTION_FAILED",
];
const values = Object.values(SITE_CODES);
expect(values.length).toBe(14);
for (const code of expected) {
expect(values).toContain(code);
}
});
it("every value is namespaced under SITE.*", { timeout: 5000 }, () => {
for (const v of Object.values(SITE_CODES)) {
expect(v.startsWith("SITE.")).toBe(true);
}
});
it("SiteError preserves code, message, cause, and siteCode alias", {
timeout: 5000,
}, () => {
const cause = new Error("boom");
const err = new SiteError(SITE_CODES.SITE_NOT_FOUND, "missing", {
cause,
context: { statusCode: 404 },
});
expect(err).toBeInstanceOf(SiteError);
expect(err.code).toBe(SITE_CODES.SITE_NOT_FOUND);
expect(err.siteCode).toBe(SITE_CODES.SITE_NOT_FOUND);
expect(err.message).toBe("missing");
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,79 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-site`.
*
* Every failure path raises a {@link SiteError} carrying one of these
* `SITE.*` codes. Consumers branch on the string value (e.g.
* `error.code === SITE_CODES.SITE_NOT_FOUND`).
*
* - `INVALID_OPTIONS` — `createSitePlugin` was called with options
* failing `SitePluginOptionsSchema`. HTTP 500.
* - `SERVICE_NOT_AVAILABLE` — `onInit`/`onReady` couldn't resolve a
* required slot (`db`, `app`). HTTP 500.
* - `SITE_NOT_FOUND` — `getById`/`update`/`addDomain`/`removeDomain`
* referenced a missing site id. HTTP 404.
* - `SITE_LIMIT_EXCEEDED` — create would push the org's site count above
* `maxSitesPerOrg`. HTTP 400.
* - `DOMAIN_LIMIT_EXCEEDED` — create / addDomain would push the site's
* domain count above `maxDomainsPerSite`. HTTP 400.
* - `DOMAIN_ALREADY_CLAIMED` — addDomain ran against a domain already
* present on the site. HTTP 409.
* - `LAST_DOMAIN_REMOVAL` — removeDomain would leave the site with zero
* domains. HTTP 400.
* - `DOMAIN_NOT_FOUND` — resolve was called with a domain unknown to any
* site. HTTP 404.
* - `INVALID_DOMAIN` — domain string failed `DomainSchema` regex
* validation. HTTP 400.
* - `VALIDATION` — generic input validation failure surfaced from a Zod
* boundary. HTTP 400.
* - `BOOTSTRAP_FAILED` — `createDefaultSite` failed for a newly emitted
* `organization:created` event. HTTP 500.
* - `STORAGE_ERROR` — a SurrealDB call failed unexpectedly. HTTP 502.
* - `ROUTE_PREFIX_COLLISION` — `routePrefix` collides with previously
* mounted routes. HTTP 500.
* - `RESOLUTION_FAILED` — internal failure path inside
* `DomainResolverService.resolve` (non-Error throwable from the
* driver). HTTP 502.
*/
export const SITE_CODES = defineCodes("SITE", [
"INVALID_OPTIONS",
"SERVICE_NOT_AVAILABLE",
"SITE_NOT_FOUND",
"SITE_LIMIT_EXCEEDED",
"DOMAIN_LIMIT_EXCEEDED",
"DOMAIN_ALREADY_CLAIMED",
"LAST_DOMAIN_REMOVAL",
"DOMAIN_NOT_FOUND",
"INVALID_DOMAIN",
"VALIDATION",
"BOOTSTRAP_FAILED",
"STORAGE_ERROR",
"ROUTE_PREFIX_COLLISION",
"RESOLUTION_FAILED",
] as const);
/** Union of all `SITE.*` string-literal codes. */
export type SiteErrorCode = (typeof SITE_CODES)[keyof typeof SITE_CODES];
/**
* Concrete error class for the api-plugin-site package. Extends
* {@link SuaveplanError} so `instanceof SiteError` narrows `code` to
* {@link SiteErrorCode}.
*/
export class SiteError extends SuaveplanError<SiteErrorCode> {
public readonly siteCode: SiteErrorCode;
public constructor(
code: SiteErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.siteCode = code;
}
}

View File

@@ -0,0 +1,130 @@
// Side-effect imports — surface the error registry + the ServiceRegistry
// augmentation to every consumer importing the package barrel.
import "./error-codes/error-codes.js";
import "./services/registry-augmentation.js";
// Error codes
export {
SITE_CODES,
SiteError,
type SiteErrorCode,
} from "./error-codes/error-codes.js";
// Integration — recommended middleware composition
export {
MIDDLEWARE_RECOMMENDATIONS,
type MiddlewareRecommendation,
type RecommendedRateLimit,
type SiteAuditCategory,
type SiteRouteGroup,
} from "./integration/middleware-recommendations.js";
// Middleware
export {
createDomainResolverMiddleware,
type DomainResolverMiddleware,
} from "./middleware/domain-resolver.js";
// OTel
export {
getSiteInstruments,
resetSiteInstruments,
type SiteInstruments,
} from "./otel/instruments.js";
// Plugin
export {
type CreateSitePluginOptions,
createSitePlugin,
} from "./plugin/plugin.js";
// Routes
export {
createPrivateSiteRoutes,
type PrivateSiteRoutesDeps,
} from "./routes/private-routes.js";
export {
createPublicSiteRoutes,
type PublicSiteRoutesDeps,
} from "./routes/public-routes.js";
// Schemas — domain
export {
type AddDomainRequest,
AddDomainRequestSchema,
type AddDomainResponse,
AddDomainResponseSchema,
type DomainResolveParam,
DomainResolveParamSchema,
type DomainResolveResponse,
DomainResolveResponseSchema,
DomainSchema,
type RemoveDomainRequest,
RemoveDomainRequestSchema,
type RemoveDomainResponse,
RemoveDomainResponseSchema,
} from "./schemas/domain.js";
// Schemas — options
export {
DEFAULT_CONFIG,
mergeConfig,
type SitePluginConfig,
type SitePluginOptionsInput,
SitePluginOptionsSchema,
} from "./schemas/options.js";
// Schemas — push
export {
type ApplePushConfig,
ApplePushConfigSchema,
type FirebasePushConfig,
FirebasePushConfigSchema,
type PushConfig,
PushConfigSchema,
type PushConfigValidationResponse,
PushConfigValidationResponseSchema,
validatePushConfig,
} from "./schemas/push.js";
// Schemas — site
export {
type CreateSite,
CreateSiteSchema,
RecordIdSchema,
type Site,
type SiteIdParam,
SiteIdParamSchema,
type SitePublicInfo,
SitePublicInfoSchema,
SiteSchema,
SiteSettingsSchema,
SiteStateNumberSchema,
SiteThemeSchema,
type UpdateSite,
UpdateSiteSchema,
} from "./schemas/site.js";
// Service — db port + normaliser
export type { SiteDb } from "./service/db-port.js";
// Service — domain resolver
export {
DomainResolverService,
type DomainResolverServiceOptions,
} from "./service/domain-resolver.js";
export { normalizeDomain } from "./service/normalize-domain.js";
// Service — site
export {
SiteService,
type SiteServiceOptions,
} from "./service/site.js";
// Types
export type {
DomainResolutionResult,
OrganizationCreatedEvent,
ResolvedOrganization,
SiteBootstrapFailedEvent,
SiteEventMap,
SitePluginLogger,
SiteSettings,
SiteSettingsUpdatedEvent,
SiteStateNumber,
SiteTheme,
} from "./types/types.js";
export {
SITE_STATE_ACTIVE,
SITE_STATE_ARCHIVED,
SITE_STATE_MAINTENANCE,
SITE_STATE_SUSPENDED,
} from "./types/types.js";

View File

@@ -0,0 +1,201 @@
/**
* Integration test proving the documented host-app middleware
* composition pattern works end-to-end. Boots a real Hono app with:
*
* 1. `createAuditMiddleware` from `@suaveplan/api-plugin-audit` —
* mounted OUTERMOST so every request, including rate-limited
* ones, is observed.
* 2. `rateLimit` from `@suaveplan/api-plugin-rate-limit` — mounted
* on the site prefix.
* 3. `createPrivateSiteRoutes` from this package — mounted under
* the same prefix.
*
* Asserts (a) site row is persisted (site behaviour unchanged), (b)
* `X-RateLimit-*` headers present on the response, (c) exactly one
* `AuditEvent` is written into the in-memory audit sink.
*
* NO mocks — real instances of every plugin's public factory.
*
* Spec hook: `document-site-middleware-integration` →
* `## ADDED Requirements::Host-app middleware composition pattern is integration-tested`.
*/
import { createAuditMiddleware } from "@suaveplan/api-plugin-audit";
import {
createDefaultStorage,
createRateLimitService,
RateLimitPluginOptionsSchema,
rateLimit,
resetRateLimitInstruments,
} from "@suaveplan/api-plugin-rate-limit";
import {
type AuditEvent,
AuditLogger,
MemoryAuditAdapter,
} from "@suaveplan/audit-logging";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { createPrivateSiteRoutes } from "../routes/private-routes.js";
import { mergeConfig } from "../schemas/options.js";
import type { SiteDb } from "../service/db-port.js";
import { SiteService } from "../service/site.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let clockNow = 1_000_000;
const clock = () => clockNow;
function memoryDb(): SiteDb {
const sites = new Map<string, Record<string, unknown>>();
let counter = 0;
return {
query: async (sql, params) => {
const text = sql.replace(/\s+/g, " ").trim();
if (text.startsWith("SELECT * FROM site WHERE id =")) {
const id = params?.[0] as string;
const row = sites.get(id);
return (row ? [row] : []) as never;
}
if (
text.startsWith(
"SELECT count() as count FROM site WHERE organization_id"
)
) {
return [{ count: 0 }] as never;
}
return [] as never;
},
create: async (_t, d) => {
counter += 1;
const id = `site:${counter}`;
const row = { ...(d as object), id };
sites.set(id, row as Record<string, unknown>);
return row as never;
},
update: async (_t, id, patch) => {
const row = sites.get(id);
if (!row) return null;
const merged = { ...row, ...(patch as Record<string, unknown>) };
sites.set(id, merged);
return merged as never;
},
};
}
beforeEach(() => {
clockNow = 1_000_000;
resetRateLimitInstruments();
});
afterEach(() => {
resetRateLimitInstruments();
});
describe("middleware composition (audit + rate-limit + site routes)", () => {
it("composes audit + rate-limit + site routes in the documented order", {
timeout: 5000,
}, async () => {
// ── 1. Site service + routes ────────────────────────────────
const db = memoryDb();
const svc = new SiteService(db, mergeConfig());
const siteRoutes = createPrivateSiteRoutes({ siteService: svc });
// ── 2. Rate-limit service tuned for the site prefix ────────
const rateLimitConfig = RateLimitPluginOptionsSchema.parse({
defaultLimit: 30,
defaultWindowMs: 60_000,
routes: [
{
id: "private:write",
path: "/sites",
limit: 30,
windowMs: 60_000,
},
],
});
const rateLimitService = createRateLimitService({
config: rateLimitConfig,
storage: createDefaultStorage({ clock }),
clock,
logger: noopLogger,
});
// ── 3. Audit logger backed by in-memory sink ───────────────
const auditSink = new MemoryAuditAdapter();
const auditLogger = new AuditLogger({
storage: auditSink,
secretKey: "x".repeat(32),
enableChainIntegrity: false,
});
const auditMiddleware = createAuditMiddleware({
logger: auditLogger,
config: {
sink: auditSink,
secretKey: "x".repeat(32),
samplingRate: 1,
excludePaths: [],
redactQueryParams: ["token"],
redactHeaders: ["authorization"],
redactBodyFields: ["password"],
recordResponseSize: false,
recordRequestBody: false,
enableChainIntegrity: false,
rng: () => 0,
},
hostLogger: noopLogger,
});
// ── 4. Compose: audit OUTERMOST → rate-limit → site routes ─
const app = new Hono();
app.use("*", auditMiddleware);
app.use("/sites/*", rateLimit({ service: rateLimitService }));
app.use("/sites", rateLimit({ service: rateLimitService }));
app.route("/", siteRoutes);
// ── 5. Exercise a write path ───────────────────────────────
const res = await app.request(
new Request("http://host.example/sites", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": "1.2.3.4",
"x-tenant-id": "org-1",
},
body: JSON.stringify({
organization_id: "organization:1",
name: "Acme",
domains: ["acme.example.com"],
}),
})
);
// ── 6a. Site behaviour preserved ───────────────────────────
expect(res.status).toBe(201);
const body = (await res.json()) as { id: string; name: string };
expect(body.name).toBe("Acme");
// ── 6b. Rate-limit headers surfaced ────────────────────────
const limitHeader = res.headers.get("X-RateLimit-Limit");
expect(limitHeader).not.toBeNull();
expect(Number(limitHeader)).toBeGreaterThan(0);
// ── 6c. Audit event recorded ───────────────────────────────
const events = auditSink.snapshot();
expect(events).toHaveLength(1);
const evt = events[0] as AuditEvent;
const details = evt.details as { method: string; path: string };
expect(details.method).toBe("POST");
expect(details.path).toBe("/sites");
});
});

View File

@@ -0,0 +1,92 @@
# `middleware-recommendations`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MIDDLEWARE_RECOMMENDATIONS` is a frozen, typed bundle of recommended rate-limit and audit configuration for each documented site route group. It exists so host applications wiring the site plugin together with `@suaveplan/api-plugin-rate-limit` and `@suaveplan/api-plugin-audit` can derive their middleware configuration from a single canonical source instead of re-deriving the trade-offs from first principles.
The site plugin deliberately ships zero runtime dependencies on the rate-limit and audit plugins — composition stays in the host via the Hono `app.use(prefix, middleware)` pattern. This module exposes the per-route-group knobs and the rationale behind each number; consumers translate them into the input shape the middleware plugins expect.
## Features
- Three canonical route groups: `public:resolve`, `public:read`, `private:write` — one entry per group.
- Each entry carries a `RecommendedRateLimit` (`limit`, `windowMs`, `pathPattern`) and a `SiteAuditCategory` (`system.lookup`, `data.read`, `data.mutation`).
- A human-readable `rationale` string explains why the numbers were chosen.
- The bundle is `Object.freeze`'d at every level — callers can read but cannot mutate the shared bundle.
- Pure data: zero runtime dependencies on `api-plugin-rate-limit` or `api-plugin-audit`; the types are local.
## Usage: Basic Example
```ts
import { MIDDLEWARE_RECOMMENDATIONS } from "@suaveplan/api-plugin-site";
const writes = MIDDLEWARE_RECOMMENDATIONS.find(
(r) => r.routeGroup === "private:write"
);
// writes.rateLimit.limit === 30 requests / 60s
// writes.auditCategory === "data.mutation"
```
## Usage: Advanced Example
Drive the rate-limit plugin's configuration directly from the recommendations:
```ts
import {
MIDDLEWARE_RECOMMENDATIONS,
type SiteRouteGroup,
} from "@suaveplan/api-plugin-site";
import {
createDefaultStorage,
createRateLimitService,
rateLimit,
RateLimitPluginOptionsSchema,
} from "@suaveplan/api-plugin-rate-limit";
const groups: SiteRouteGroup[] = [
"public:resolve",
"public:read",
"private:write",
];
const config = RateLimitPluginOptionsSchema.parse({
defaultLimit: 100,
defaultWindowMs: 60_000,
routes: MIDDLEWARE_RECOMMENDATIONS.filter((r) => groups.includes(r.routeGroup)).map(
(r, i) => ({
id: r.routeGroup,
method: "ALL",
pathPattern: r.rateLimit.pathPattern,
limit: r.rateLimit.limit,
windowMs: r.rateLimit.windowMs,
priority: i,
})
),
});
const clock = () => Date.now();
const service = createRateLimitService({
config,
storage: createDefaultStorage({ clock }),
clock,
logger: console,
});
app.use("/sites/*", rateLimit({ service }));
```
## API Reference
- `MIDDLEWARE_RECOMMENDATIONS: readonly MiddlewareRecommendation[]` — frozen three-element array.
- `MiddlewareRecommendation` — `{ routeGroup, rationale, rateLimit, auditCategory }`.
- `SiteRouteGroup` — `"public:resolve" | "public:read" | "private:write"`.
- `SiteAuditCategory` — `"data.read" | "data.mutation" | "system.lookup"`.
- `RecommendedRateLimit` — `{ limit: number, windowMs: number, pathPattern: string }`.
## Implementation Notes
- Numbers reflect a public, multi-tenant SaaS profile. Internal-only deployments should override; the bundle is documentation, not policy.
- `pathPattern` strings match the path-matcher syntax in `@suaveplan/api-plugin-rate-limit/path-matcher`.
- The bundle is deeply frozen so callers can safely share the reference across module scopes without defensive cloning.
- Consumers MAY skip the bundle entirely; it is offered for convenience and is not part of any side-effecting code path inside the site plugin.

View File

@@ -0,0 +1,66 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MIDDLEWARE_RECOMMENDATIONS } from "./middleware-recommendations.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const DOCS_PATH = resolve(
HERE,
"..",
"..",
"docs",
"middleware-integration.md"
);
describe("MIDDLEWARE_RECOMMENDATIONS", () => {
it("exposes three route groups", { timeout: 5000 }, () => {
const groups = MIDDLEWARE_RECOMMENDATIONS.map((r) => r.routeGroup);
expect(groups).toEqual([
"public:resolve",
"public:read",
"private:write",
]);
});
it("private:write is tightest and audited as data.mutation", {
timeout: 5000,
}, () => {
const write = MIDDLEWARE_RECOMMENDATIONS.find(
(r) => r.routeGroup === "private:write"
);
const read = MIDDLEWARE_RECOMMENDATIONS.find(
(r) => r.routeGroup === "public:read"
);
expect(write).toBeDefined();
expect(read).toBeDefined();
expect(write?.rateLimit.limit).toBeLessThan(
read?.rateLimit.limit ?? Number.POSITIVE_INFINITY
);
expect(write?.auditCategory).toBe("data.mutation");
});
it("bundle is frozen at every level", { timeout: 5000 }, () => {
expect(Object.isFrozen(MIDDLEWARE_RECOMMENDATIONS)).toBe(true);
for (const entry of MIDDLEWARE_RECOMMENDATIONS) {
expect(Object.isFrozen(entry)).toBe(true);
expect(Object.isFrozen(entry.rateLimit)).toBe(true);
}
});
it("docs/middleware-integration.md exists and documents the composition order", {
timeout: 5000,
}, () => {
expect(existsSync(DOCS_PATH)).toBe(true);
const body = readFileSync(DOCS_PATH, "utf8");
const lowered = body.toLowerCase();
expect(lowered).toContain("app.use");
expect(lowered).toContain("audit");
expect(lowered).toContain("rate-limit");
expect(lowered).toContain("site");
// Word-count gate (excluding fenced code blocks): >= 200 words.
const prose = body.replace(/```[\s\S]*?```/g, "");
const words = prose.split(/\s+/).filter((w) => w.length > 0);
expect(words.length).toBeGreaterThanOrEqual(200);
});
});

View File

@@ -0,0 +1,90 @@
/**
* Recommended middleware configuration per documented site route group.
*
* The site plugin deliberately does NOT bind rate-limit or audit
* middleware itself — composition stays in the host application via
* the Hono `app.use(prefix, middleware)` pattern. This module exposes
* the *recommended* per-route-group knobs so callers can derive their
* own rate-limit / audit configuration without re-deriving the
* trade-offs from first principles.
*
* Consume these by mapping each entry to the corresponding plugin's
* configuration shape — see `docs/middleware-integration.md` for the
* canonical wiring example.
*/
/** Logical groups of site routes — see `routes/public-routes.ts` and
* `routes/private-routes.ts`. */
export type SiteRouteGroup = "public:resolve" | "public:read" | "private:write";
/** Recommended audit category. Maps onto `AuditEvent.type` namespaces
* consumed by `@suaveplan/api-plugin-audit`. */
export type SiteAuditCategory = "data.read" | "data.mutation" | "system.lookup";
/** Recommended rate-limit knobs per route group. Matches the input
* shape consumed by `@suaveplan/api-plugin-rate-limit`'s
* `RouteRule` constructor — `limit` per `windowMs`. */
export interface RecommendedRateLimit {
readonly limit: number;
readonly windowMs: number;
readonly pathPattern: string;
}
/** Single recommendation row. */
export interface MiddlewareRecommendation {
readonly routeGroup: SiteRouteGroup;
readonly rationale: string;
readonly rateLimit: RecommendedRateLimit;
readonly auditCategory: SiteAuditCategory;
}
/**
* Canonical recommendations. Frozen so callers cannot mutate the
* shared bundle by accident.
*
* Numbers reflect a public, multi-tenant SaaS profile:
* - `public:resolve` runs on the unauthenticated `/sites/resolve/*`
* endpoint and is hit on every incoming HTTP request that needs a
* tenant lookup; the ceiling is generous (per-IP).
* - `public:read` covers `/sites/{id}/public` which serves a small
* sanitised projection; a modest ceiling deters scraping.
* - `private:write` covers `POST /sites`, `PATCH /sites/{id}` and
* `(POST|DELETE) /sites/{id}/domains`. Writes are rare per tenant
* and warrant the tightest limit AND structured audit.
*/
export const MIDDLEWARE_RECOMMENDATIONS: readonly MiddlewareRecommendation[] =
Object.freeze([
Object.freeze({
routeGroup: "public:resolve",
rationale:
"Domain → site lookup; called on every inbound request that needs a tenant. Generous per-IP ceiling.",
rateLimit: Object.freeze({
limit: 600,
windowMs: 60_000,
pathPattern: "/sites/resolve/*",
}),
auditCategory: "system.lookup",
}),
Object.freeze({
routeGroup: "public:read",
rationale:
"Public site projection; cheap to serve but valuable to scrape. Moderate per-IP ceiling.",
rateLimit: Object.freeze({
limit: 120,
windowMs: 60_000,
pathPattern: "/sites/:id/public",
}),
auditCategory: "data.read",
}),
Object.freeze({
routeGroup: "private:write",
rationale:
"Authenticated CRUD on site rows / domains. Writes are rare per tenant and warrant a strict ceiling + full audit trail.",
rateLimit: Object.freeze({
limit: 30,
windowMs: 60_000,
pathPattern: "/sites/*",
}),
auditCategory: "data.mutation",
}),
]);

View File

@@ -0,0 +1,54 @@
# `middleware/domain-resolver.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory that builds a Hono middleware which resolves the incoming request's `Host` header to a `site` + `organization` and injects them into the request context. The middleware is the canonical hook applications install at the top of their route stack so downstream handlers can use `c.get("site")` / `c.get("organization")` without re-resolving on every endpoint. It is exposed as a service (`domainResolverMiddleware`) by the plugin's `onInit` so unrelated plugins can install it positionally without importing the package directly.
The middleware never errors on miss — it falls through with no context keys set. This matches the upstream behaviour and keeps the same instance reusable on route trees containing both site-bound and unbound endpoints.
## Features
- Reads `Host` then `X-Forwarded-Host` so reverse-proxied deployments work.
- Strips the port via `host.split(":")` before lookup.
- Normalises through `normalizeDomain` so the value passed to the resolver is canonical.
- Injects `site`, `organization`, `siteId`, `organizationId` on success.
- Silent fall-through on miss — downstream is free to 404 or render maintenance.
## Usage: Basic
```ts
import { createDomainResolverMiddleware } from "@suaveplan/api-plugin-site/middleware/domain-resolver";
const middleware = createDomainResolverMiddleware(resolver);
app.use("*", middleware);
```
## Usage: Advanced
```ts
import { createDomainResolverMiddleware } from "@suaveplan/api-plugin-site/middleware/domain-resolver";
// Wrap to enforce site presence on a specific subtree.
const middleware = createDomainResolverMiddleware(resolver);
app.use("/site", async (c, next) => {
await middleware(c, next);
if (c.get("site") === undefined) {
return c.json({ error: "Site not found for host" }, 404);
}
});
```
## API Reference
- `type DomainResolverMiddleware = (c: Context, next: Next) => Promise<void | Response>`.
- `createDomainResolverMiddleware(resolver: DomainResolverService): DomainResolverMiddleware`.
## Implementation Notes
- The middleware does not throw on a resolver error — the resolver's own error path already records the `error` outcome on the OTel counter. Throwing here would force every consumer to wrap the entire request in try/catch.
- `X-Forwarded-Host` is consulted only when `Host` is absent; if both are present we trust the proxy by deferring to the inner header set, matching the upstream convention.
- The four injected context keys are typed via the `hono` `ContextVariableMap` augmentation (consumers can re-declare for their app), but the middleware itself does not augment the global declaration to avoid namespace collisions with siblings.

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import type { SiteDb } from "../service/db-port.js";
import { DomainResolverService } from "../service/domain-resolver.js";
import { createDomainResolverMiddleware } from "./domain-resolver.js";
function dbReturning(row?: unknown): SiteDb {
return {
query: async () => (row ? [row] : []) as never,
create: async (_t, d) => ({ ...d, id: "noop" }) as never,
update: async () => null,
};
}
const hitRow = {
site: {
id: "site:1",
organization_id: "organization:1",
name: "Acme",
domains: ["example.com"],
settings: {
maintenance_mode: false,
theme: {
primary_color: "#000033",
secondary_color: "#000066",
font_family: "",
},
features: [],
},
state: 1,
created_at: "2026",
updated_at: "2026",
},
organization: { id: "organization:1", name: "ACME", slug: "acme" },
};
describe("createDomainResolverMiddleware", () => {
it("injects site + organization on a hit", { timeout: 5000 }, async () => {
const resolver = new DomainResolverService(dbReturning(hitRow));
const app = new Hono();
app.use("*", createDomainResolverMiddleware(resolver));
app.get("/ping", (c) =>
c.json({
siteId: c.get("siteId"),
organizationId: c.get("organizationId"),
hasSite: c.get("site") !== undefined,
})
);
const r = await app.request("/ping", {
headers: { Host: "https://www.Example.Com:8080" },
});
const body = (await r.json()) as {
siteId: string;
organizationId: string;
hasSite: boolean;
};
expect(body.siteId).toBe("site:1");
expect(body.organizationId).toBe("organization:1");
expect(body.hasSite).toBe(true);
});
it("falls through when host header is empty (direct unit call)", {
timeout: 5000,
}, async () => {
const resolver = new DomainResolverService(dbReturning());
const middleware = createDomainResolverMiddleware(resolver);
const captured: Record<string, unknown> = {};
const fakeCtx = {
req: {
header: (_k: string) => undefined,
},
set: (k: string, v: unknown) => {
captured[k] = v;
},
};
let nextCalled = 0;
await middleware(
fakeCtx as unknown as Parameters<typeof middleware>[0],
(async () => {
nextCalled += 1;
}) as Parameters<typeof middleware>[1]
);
expect(nextCalled).toBe(1);
expect(captured.site).toBeUndefined();
});
it("falls through silently on resolver miss", {
timeout: 5000,
}, async () => {
const resolver = new DomainResolverService(dbReturning());
const app = new Hono();
app.use("*", createDomainResolverMiddleware(resolver));
app.get("/ping", (c) =>
c.json({ hasSite: c.get("site") !== undefined })
);
const r = await app.request("/ping", {
headers: { Host: "unknown.example.com" },
});
const body = (await r.json()) as { hasSite: boolean };
expect(body.hasSite).toBe(false);
});
it("prefers Host then x-forwarded-host", { timeout: 5000 }, async () => {
let seen = "";
const customResolver = new DomainResolverService({
query: async (_sql, params) => {
seen = (params?.[0] as string) ?? "";
return [];
},
create: async (_t, d) => ({ ...d, id: "noop" }) as never,
update: async () => null,
});
const app = new Hono();
app.use("*", createDomainResolverMiddleware(customResolver));
app.get("/ping", (c) => c.text("ok"));
await app.request("/ping", {
headers: { "x-forwarded-host": "proxied.example.com" },
});
// Hono's Request constructor sets a default Host; assert the proxy header
// is used when Host is otherwise the synthetic default.
expect(seen.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,46 @@
import type { Context, Next } from "hono";
import type { DomainResolverService } from "../service/domain-resolver.js";
import { normalizeDomain } from "../service/normalize-domain.js";
/** Signature of the Hono middleware emitted by the factory. */
export type DomainResolverMiddleware = (
c: Context,
next: Next
) => Promise<void>;
/**
* Factory — builds a Hono middleware that resolves the request's `Host`
* (or `X-Forwarded-Host`) to a site + organization and injects them
* into the request context under the keys:
*
* - `site` — full `Site` record
* - `organization` — `{id, name, slug}` summary
* - `siteId`, `organizationId` — convenience id slots
*
* Falls through silently if no host header is present or the resolver
* misses — downstream handlers decide whether to 404. This matches the
* upstream behaviour and lets the same middleware be installed on
* route trees that contain both site-bound and unbound endpoints.
*/
export function createDomainResolverMiddleware(
resolver: DomainResolverService
): DomainResolverMiddleware {
return async (c, next) => {
const host =
c.req.header("host") ?? c.req.header("x-forwarded-host") ?? "";
if (host.length === 0) {
await next();
return;
}
const rawDomain = host.split(":")[0] as string;
const normalized = normalizeDomain(rawDomain);
const result = await resolver.resolve(normalized);
if (result.success && result.site && result.organization) {
c.set("site", result.site);
c.set("organization", result.organization);
c.set("siteId", result.site.id);
c.set("organizationId", result.organization.id);
}
await next();
};
}

View File

@@ -0,0 +1,63 @@
# `otel/instruments.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the lazy-built OpenTelemetry instrument bundle (`SiteInstruments`) that every observable code path in `@suaveplan/api-plugin-site` emits to. Instruments are constructed against the global `MeterProvider` on first call and cached so each call to `getSiteInstruments()` reuses the same `Counter`/`Histogram` handles. `resetSiteInstruments()` drops the cache so tests can install a fresh `MeterProvider` and re-bind against it — the canonical suaveplan pattern used by every other plugin's OTel module.
## Features
- `sitesCreatedTotal{outcome}` counter — `success` | `error`.
- `sitesUpdatedTotal{kind}` counter — `settings` | `fields` | `state` | `domain`.
- `domainResolutionsTotal{outcome}` counter — `hit` | `miss` | `error`.
- `domainResolutionDurationMs` histogram — wall-clock ms.
- `bootstrapFailedTotal` counter — incremented when `createDefaultSite` fails during an `organization:created` event.
- Cache reset for test rebinding via `resetSiteInstruments()`.
## Usage: Basic
```ts
import { getSiteInstruments } from "@suaveplan/api-plugin-site/otel/instruments";
const inst = getSiteInstruments();
inst.sitesCreatedTotal.add(1, { outcome: "success" });
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import {
getSiteInstruments,
resetSiteInstruments,
} from "@suaveplan/api-plugin-site/otel/instruments";
beforeAll(() => {
const mp = new MeterProvider();
metrics.setGlobalMeterProvider(mp);
resetSiteInstruments();
});
it("counts a resolution miss", () => {
const inst = getSiteInstruments();
inst.domainResolutionsTotal.add(1, { outcome: "miss" });
});
```
## API Reference
- `interface SiteInstruments` — five readonly handles.
- `getSiteInstruments(): SiteInstruments` — lazy + cached.
- `resetSiteInstruments(): void` — drop cache so the next call rebinds.
## Implementation Notes
- The cache lives at module scope. Reset is required between tests that swap the global provider — without it, an old handle pinned to a destroyed provider would record forever into the void.
- Metric names follow `<package>.<noun>_total` / `_duration_ms` convention so dashboards can grep across plugins consistently.
- The histogram unit is `ms` so collectors can render p50/p95/p99 latency without additional conversion.
- The counter / histogram handles returned by `getSiteInstruments()` are safe to retain across requests — they are thin proxies over the underlying `MeterProvider`'s metric registry, and recreating them on every call would defeat the purpose of OTel's batched export pipeline. Tests that swap providers therefore MUST call `resetSiteInstruments()` before the next read so the next handle points at the new provider.
- This module avoids any side effects at import time so consumers can tree-shake it cleanly when they disable observability via a build flag — the `getMeter` call is gated behind the first invocation of `getSiteInstruments`.

View File

@@ -0,0 +1,49 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { getSiteInstruments, resetSiteInstruments } from "./instruments.js";
describe("site instruments", () => {
it("getSiteInstruments returns a cached bundle", {
timeout: 5000,
}, () => {
resetSiteInstruments();
const a = getSiteInstruments();
const b = getSiteInstruments();
expect(a).toBe(b);
});
it("resetSiteInstruments rebinds against a fresh MeterProvider", {
timeout: 5000,
}, async () => {
resetSiteInstruments();
const first = getSiteInstruments();
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetSiteInstruments();
const second = getSiteInstruments();
expect(second).not.toBe(first);
second.sitesCreatedTotal.add(1, { outcome: "success" });
second.sitesUpdatedTotal.add(1, { kind: "fields" });
second.domainResolutionsTotal.add(1, { outcome: "hit" });
second.domainResolutionDurationMs.record(7);
second.bootstrapFailedTotal.add(1);
await provider.forceFlush();
const collected = exporter.getMetrics();
expect(collected.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,76 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OpenTelemetry instrument bundle for `@suaveplan/api-plugin-site`.
*
* - `sitesCreatedTotal{outcome}` — `outcome ∈ "success" | "error"`.
* - `sitesUpdatedTotal{kind}` — `kind ∈ "settings" | "fields" | "state" |
* "domain"`.
* - `domainResolutionsTotal{outcome}` — `outcome ∈ "hit" | "miss" |
* "error"`.
* - `domainResolutionDurationMs` — histogram of `resolve()` wall-clock
* duration in ms.
* - `bootstrapFailedTotal` — counter incremented when
* `createDefaultSite` fails during `organization:created` handling.
*/
export interface SiteInstruments {
readonly sitesCreatedTotal: Counter;
readonly sitesUpdatedTotal: Counter;
readonly domainResolutionsTotal: Counter;
readonly domainResolutionDurationMs: Histogram;
readonly bootstrapFailedTotal: Counter;
}
let cached: SiteInstruments | undefined;
/**
* Lazily build (and cache) the instrument bundle against the current
* global `MeterProvider`. Call {@link resetSiteInstruments} in test
* setup to force a re-bind against a freshly-installed provider.
*/
export function getSiteInstruments(): SiteInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-site");
cached = {
sitesCreatedTotal: meter.createCounter("site.sites_created_total", {
description: "Total site create attempts, partitioned by outcome",
}),
sitesUpdatedTotal: meter.createCounter("site.sites_updated_total", {
description:
"Total site update operations, partitioned by kind label",
}),
domainResolutionsTotal: meter.createCounter(
"site.domain_resolutions_total",
{
description:
"Total domain resolutions, partitioned by outcome label",
}
),
domainResolutionDurationMs: meter.createHistogram(
"site.domain_resolution_duration_ms",
{
description:
"Wall-clock duration of DomainResolverService.resolve() in ms",
unit: "ms",
}
),
bootstrapFailedTotal: meter.createCounter(
"site.bootstrap_failed_total",
{
description:
"Total failures in createDefaultSite during organization:created handling",
}
),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getSiteInstruments} call rebinds against the global
* `MeterProvider` in effect at that point. Tests call this after
* installing a fresh `MeterProvider`.
*/
export function resetSiteInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,66 @@
# `plugin/plugin.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns `createSitePlugin`, the canonical factory that produces the `PluginDescriptor` consumed by `@suaveplan/api-plugin`'s `PluginRegistry`. The factory composes every other module in this package — `SitePluginOptionsSchema` validates options at the boundary, the `SiteService` + `DomainResolverService` are constructed once, the Hono middleware is built and exposed through `ServiceRegistry`, the public + private route trees are mounted under the configured `routePrefix`, and the optional `organization:created` listener is wired when `autoCreateDefaultSite` is on. This file is the single integration point — every consumer of the package builds its descriptor through this one function.
The lifecycle is the canonical 4-phase shape: validate-at-construction, build-services in `onInit`, mount-routes in `onReady`, drop-listener in `onClose`. Each phase fails fast with a typed `SiteError` carrying a `SITE.*` code so misconfigurations are visible at registry intake rather than deferred to the first request.
## Features
- Zod-validated option boundary — `SITE.INVALID_OPTIONS` on bad config.
- Resolves `db` from `ServiceRegistry`; throws `SITE.SERVICE_NOT_AVAILABLE` when missing.
- Constructs and registers `siteService`, `domainResolver`, `domainResolverMiddleware`.
- Optional `organization:created` listener for default-site auto-bootstrap, with `SITE.BOOTSTRAP_FAILED` event emission on error and the `site.bootstrap_failed_total` counter incremented on each failure.
- Route mounting under `config.routePrefix` (defaults to `/api`) with prefix-collision detection via `SITE.ROUTE_PREFIX_COLLISION`.
- `optionalDependencies: ["database", "auth", "events"]` so orchestrators topo-sort the site plugin after siblings without making them mandatory.
- Emits `site:initialized` and `site:ready` lifecycle events.
## Usage: Basic
```ts
import { createSitePlugin } from "@suaveplan/api-plugin-site";
registry.register(createSitePlugin({ maxSitesPerOrg: 25 }));
```
## Usage: Advanced
```ts
import { createSitePlugin } from "@suaveplan/api-plugin-site";
const plugin = createSitePlugin({
routePrefix: "/v1",
autoCreateDefaultSite: true,
defaultDomainPattern: "{slug}.acme.com",
enableDomainResolution: true,
});
await registry.register(plugin, { db: surrealAdapter, app: openApiHono });
await registry.start();
// Downstream plugin can now resolve the service:
const siteService = ctx.getService("siteService")!;
await siteService.create({
organization_id: "organization:abc",
name: "Customer Site",
domains: ["customer.acme.com"],
});
```
## API Reference
- `createSitePlugin(options?: CreateSitePluginOptions): PluginDescriptor<SitePluginConfig>`
- `type CreateSitePluginOptions = SitePluginOptionsInput`
- The descriptor name is `"site"`, version `"0.1.0"`.
## Implementation Notes
- Service handles are stored in closure-scoped variables and bound via a private `bind` helper so the `onReady` hook can reuse the same instances constructed in `onInit`. The `mustService` / `mustResolver` guards trip when an orchestrator runs `onReady` before `onInit` (which the canonical registry never does, but tests / misuse can).
- The plugin treats the host event bus as `EventBusLike<SiteEventMap>` so the cumulative event typing is enforced inside the plugin without bleeding into the global `EventBusLike` declaration on the host.
- Route mounting is keyed by detecting any existing path starting with `${prefix}/sites/`; a collision raises `SITE.ROUTE_PREFIX_COLLISION` so two plugins can't silently overwrite each other.
- `enableDomainResolution: false` suppresses ONLY the public router (which contains the resolve endpoint); the private router always mounts so CRUD remains usable even when public lookup is disabled.

View File

@@ -0,0 +1,403 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import { SITE_CODES, SiteError } from "../error-codes/error-codes.js";
import type { SiteDb } from "../service/db-port.js";
import type { SiteService } from "../service/site.js";
import type {
OrganizationCreatedEvent,
SiteBootstrapFailedEvent,
SiteEventMap,
SiteSettingsUpdatedEvent,
} from "../types/types.js";
import { createSitePlugin } from "./plugin.js";
interface MemDb extends SiteDb {
readonly sites: Map<string, Record<string, unknown>>;
}
function memoryDb(): MemDb {
const sites = new Map<string, Record<string, unknown>>();
let counter = 0;
const db: MemDb = {
sites,
query: async (sql, params) => {
const text = sql.replace(/\s+/g, " ").trim();
if (text.startsWith("SELECT * FROM site WHERE id =")) {
const id = params?.[0] as string;
const row = sites.get(id);
return (row ? [row] : []) as never;
}
if (
text.startsWith(
"SELECT count() as count FROM site WHERE organization_id"
)
) {
const orgId = params?.[0] as string;
const c = [...sites.values()].filter(
(s) =>
(s as { organization_id: string }).organization_id ===
orgId
).length;
return (c === 0 ? [] : [{ count: c }]) as never;
}
return [] as never;
},
create: async (_t, d) => {
counter += 1;
const id = `site:${counter}`;
const row = { ...(d as object), id };
sites.set(id, row as Record<string, unknown>);
return row as never;
},
update: async (_t, id, patch) => {
const row = sites.get(id);
if (!row) return null;
const merged = { ...row, ...(patch as Record<string, unknown>) };
sites.set(id, merged);
return merged as never;
},
};
return db;
}
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeServices(db?: MemDb, app?: OpenAPIHono): Map<string, unknown> {
const map = new Map<string, unknown>();
if (db) map.set("db", db);
if (app) map.set("app", app);
return map;
}
function makeCtx(
config: unknown,
services: Map<string, unknown>,
eventBus: EventBusLike<SiteEventMap>
): ReturnType<typeof createPluginContext<unknown>> {
return createPluginContext({
logger: noopLogger,
eventBus: eventBus as unknown as EventBusLike,
signal: new AbortController().signal,
config,
services,
});
}
describe("createSitePlugin", () => {
it("rejects invalid options with SITE.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
createSitePlugin({ maxSitesPerOrg: -1 } as never);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.INVALID_OPTIONS);
});
it("descriptor name is 'site' and version is '0.1.0'", {
timeout: 5000,
}, () => {
const d = createSitePlugin();
expect(d.name).toBe("site");
expect(d.version).toBe("0.1.0");
expect(d.optionalDependencies).toEqual(["database", "auth", "events"]);
});
it("onInit throws SERVICE_NOT_AVAILABLE when db is missing", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin();
const ctx = makeCtx(plugin.defaults, makeServices(), new EventBus());
let caught: unknown;
try {
await plugin.onInit?.(ctx);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(
SITE_CODES.SERVICE_NOT_AVAILABLE
);
});
it("onInit registers services and emits site:initialized", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: false });
const services = makeServices(memoryDb());
const bus = new EventBus<SiteEventMap>();
const lifecycleEvents: string[] = [];
bus.on("site:initialized", () => lifecycleEvents.push("initialized"));
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
expect(services.has("siteService")).toBe(true);
expect(services.has("domainResolver")).toBe(true);
expect(services.has("domainResolverMiddleware")).toBe(true);
expect(lifecycleEvents).toEqual(["initialized"]);
});
it("onReady mounts routes under routePrefix and emits site:ready", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({
autoCreateDefaultSite: false,
routePrefix: "/v1",
});
const app = new OpenAPIHono();
const services = makeServices(memoryDb(), app);
const bus = new EventBus<SiteEventMap>();
const readyMarks: number[] = [];
bus.on("site:ready", () => readyMarks.push(1));
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
const r = await app.request("/v1/sites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
organization_id: "organization:1",
name: "Acme",
domains: ["acme.example.com"],
}),
});
expect(r.status).toBe(201);
expect(readyMarks).toEqual([1]);
});
it("onReady throws SERVICE_NOT_AVAILABLE when app is missing", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: false });
const services = makeServices(memoryDb());
const bus = new EventBus<SiteEventMap>();
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
let caught: unknown;
try {
await plugin.onReady?.(ctx);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(
SITE_CODES.SERVICE_NOT_AVAILABLE
);
});
it("auto-creates a default site on organization:created", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: true });
const db = memoryDb();
const services = makeServices(db, new OpenAPIHono());
const bus = new EventBus<SiteEventMap>();
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
const event: OrganizationCreatedEvent = {
id: "organization:abc",
name: "Acme Co",
slug: "acme-co",
};
bus.emit("organization:created", event);
// EventBus.emit is synchronous; allow microtask for async listener.
await new Promise((r) => setTimeout(r, 0));
expect(db.sites.size).toBe(1);
});
it("emits site:bootstrap:failed when createDefaultSite throws", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({
autoCreateDefaultSite: true,
maxSitesPerOrg: 1,
});
const db = memoryDb();
const services = makeServices(db, new OpenAPIHono());
const bus = new EventBus<SiteEventMap>();
const failures: SiteBootstrapFailedEvent[] = [];
bus.on("site:bootstrap:failed", (e) => {
failures.push(e);
});
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
// Pre-fill the org's site quota so the auto-bootstrap fails.
const siteService = services.get("siteService") as SiteService;
await siteService.create({
organization_id: "organization:abc",
name: "Preload",
domains: ["pre.example.com"],
});
bus.emit("organization:created", {
id: "organization:abc",
name: "Acme",
slug: "acme",
});
await new Promise((r) => setTimeout(r, 0));
expect(failures.length).toBe(1);
expect(failures[0]?.organizationId).toBe("organization:abc");
});
it("onClose is safe when autoCreateDefaultSite is false (no unsubscribe)", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: false });
const db = memoryDb();
const services = makeServices(db, new OpenAPIHono());
const bus = new EventBus<SiteEventMap>();
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
await plugin.onClose?.(ctx);
// Listener was never installed; emitting after close must not auto-bootstrap.
bus.emit("organization:created", {
id: "organization:closed",
name: "x",
slug: "x",
});
await new Promise((r) => setTimeout(r, 0));
expect(db.sites.size).toBe(0);
});
it("auto-bootstrap catches non-Error throwables", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: true });
const db = memoryDb();
const services = makeServices(db, new OpenAPIHono());
const bus = new EventBus<SiteEventMap>();
const failures: SiteBootstrapFailedEvent[] = [];
bus.on("site:bootstrap:failed", (e) => failures.push(e));
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
const siteService = services.get("siteService") as SiteService;
// Replace createDefaultSite to throw a non-Error throwable.
siteService.createDefaultSite = async () => {
const bad: unknown = "string-throwable";
throw bad;
};
bus.emit("organization:created", {
id: "organization:str",
name: "Acme",
slug: "acme",
});
await new Promise((r) => setTimeout(r, 0));
expect(failures.length).toBe(1);
expect(failures[0]?.message).toBe("createDefaultSite failed");
});
it("onClose drops the organization:created listener", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: true });
const db = memoryDb();
const services = makeServices(db, new OpenAPIHono());
const bus = new EventBus<SiteEventMap>();
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
await plugin.onClose?.(ctx);
bus.emit("organization:created", {
id: "organization:gone",
name: "x",
slug: "x",
});
await new Promise((r) => setTimeout(r, 0));
expect(db.sites.size).toBe(0);
});
it("emits site:settings:updated through the bus when updateSettings is called", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: false });
const db = memoryDb();
const services = makeServices(db, new OpenAPIHono());
const bus = new EventBus<SiteEventMap>();
const updates: SiteSettingsUpdatedEvent[] = [];
bus.on("site:settings:updated", (e) => {
updates.push(e);
});
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
const siteService = services.get("siteService") as SiteService;
const site = await siteService.create({
organization_id: "organization:1",
name: "Acme",
domains: ["acme.example.com"],
});
await siteService.updateSettings(site.id, { features: ["beta"] });
expect(updates.length).toBe(1);
expect(updates[0]?.siteId).toBe(site.id);
});
it("skips public routes when enableDomainResolution is false", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({
autoCreateDefaultSite: false,
enableDomainResolution: false,
routePrefix: "/api",
});
const app = new OpenAPIHono();
const services = makeServices(memoryDb(), app);
const bus = new EventBus<SiteEventMap>();
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
const r = await app.request("/api/sites/resolve/example.com");
expect(r.status >= 400).toBe(true);
});
it("onReady before onInit throws SERVICE_NOT_AVAILABLE (mustService guard)", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: false });
const app = new OpenAPIHono();
const services = makeServices(memoryDb(), app);
const bus = new EventBus<SiteEventMap>();
const ctx = makeCtx(plugin.defaults, services, bus);
// onReady runs without onInit having bound the services.
let caught: unknown;
try {
await plugin.onReady?.(ctx);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(
SITE_CODES.SERVICE_NOT_AVAILABLE
);
});
it("detects route prefix collisions and throws ROUTE_PREFIX_COLLISION", {
timeout: 5000,
}, async () => {
const plugin = createSitePlugin({ autoCreateDefaultSite: false });
const app = new OpenAPIHono();
// Pre-mount a colliding /sites route.
app.get("/api/sites/preexisting", (c) => c.text("ok"));
const services = makeServices(memoryDb(), app);
const bus = new EventBus<SiteEventMap>();
const ctx = makeCtx(plugin.defaults, services, bus);
await plugin.onInit?.(ctx);
let caught: unknown;
try {
await plugin.onReady?.(ctx);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(
SITE_CODES.ROUTE_PREFIX_COLLISION
);
});
});

View File

@@ -0,0 +1,225 @@
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import type { EventBusLike } from "@suaveplan/types";
import { SITE_CODES, SiteError } from "../error-codes/error-codes.js";
import { createDomainResolverMiddleware } from "../middleware/domain-resolver.js";
import { getSiteInstruments } from "../otel/instruments.js";
import { createPrivateSiteRoutes } from "../routes/private-routes.js";
import { createPublicSiteRoutes } from "../routes/public-routes.js";
import {
type SitePluginConfig,
type SitePluginOptionsInput,
SitePluginOptionsSchema,
} from "../schemas/options.js";
import { DomainResolverService } from "../service/domain-resolver.js";
import { SiteService } from "../service/site.js";
import type {
OrganizationCreatedEvent,
SiteBootstrapFailedEvent,
SiteEventMap,
} from "../types/types.js";
import "../services/registry-augmentation.js";
/** Caller-supplied options for {@link createSitePlugin}. */
export type CreateSitePluginOptions = SitePluginOptionsInput;
/**
* Factory — returns a typed `PluginDescriptor` for the site plugin.
*
* Lifecycle:
*
* 1. `onInit`
* - resolves `db` from `ServiceRegistry` (errors with
* `SITE.SERVICE_NOT_AVAILABLE` if missing).
* - constructs `SiteService` + `DomainResolverService` and registers
* both, plus the `domainResolverMiddleware` factory output.
* - subscribes to `organization:created` when
* `autoCreateDefaultSite` is on.
* - emits `site:initialized`.
* 2. `onReady`
* - resolves `app` from `ServiceRegistry`.
* - mounts public + private routers under `routePrefix`.
* - emits `site:ready`.
* 3. `onClose`
* - drops the `organization:created` listener; nothing else to
* clean up because services hold no resources of their own.
*/
export function createSitePlugin(
options: CreateSitePluginOptions = {}
): PluginDescriptor<SitePluginConfig> {
const parsed = SitePluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new SiteError(
SITE_CODES.INVALID_OPTIONS,
"createSitePlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const config = parsed.data;
let siteService: SiteService | undefined;
let domainResolver: DomainResolverService | undefined;
let unsubscribe: (() => void) | undefined;
return definePlugin<SitePluginConfig>({
name: "site",
version: "0.1.0",
description:
"Multi-site / multi-domain plugin for @suaveplan/api: typed SurrealDB-backed SiteService + DomainResolverService, public + private OpenAPI routes, Hono middleware, organization:created auto-bootstrap, site:settings:updated emit, OTel observability.",
dependencies: [],
optionalDependencies: ["database", "auth", "events"],
tags: ["site", "multi-tenant", "domain", "routing"],
defaults: config,
onInit: async (ctx) =>
runOnInit(ctx, config, (svc, res, unsub) => {
siteService = svc;
domainResolver = res;
unsubscribe = unsub;
}),
onReady: async (ctx) => {
const { svc, resolver } = mustHandles(siteService, domainResolver);
return runOnReady(ctx, config, svc, resolver);
},
onClose: async () => {
if (unsubscribe) unsubscribe();
},
});
}
type BindServices = (
siteService: SiteService,
domainResolver: DomainResolverService,
unsubscribe: (() => void) | undefined
) => void;
async function runOnInit(
ctx: PluginContext<SitePluginConfig>,
config: SitePluginConfig,
bind: BindServices
): Promise<void> {
const db = ctx.getService("db");
if (db === undefined) {
throw new SiteError(
SITE_CODES.SERVICE_NOT_AVAILABLE,
"site plugin requires the `db` service in ServiceRegistry",
{ context: { missing: ["db"], statusCode: 500 } }
);
}
const eventBus = ctx.eventBus as unknown as EventBusLike<SiteEventMap>;
const siteService = new SiteService(db, config, {
logger: ctx.logger,
eventBus,
});
const domainResolver = new DomainResolverService(db, {
logger: ctx.logger,
});
ctx.registerService("siteService", siteService);
ctx.registerService("domainResolver", domainResolver);
ctx.registerService(
"domainResolverMiddleware",
createDomainResolverMiddleware(domainResolver)
);
let unsubscribe: (() => void) | undefined;
if (config.autoCreateDefaultSite) {
unsubscribe = eventBus.on(
"organization:created",
async (organization: OrganizationCreatedEvent) => {
try {
await siteService.createDefaultSite(organization);
ctx.logger.info?.(
"site plugin created default site for organization",
{ organizationId: organization.id }
);
} catch (cause) {
const message =
cause instanceof Error
? cause.message
: "createDefaultSite failed";
getSiteInstruments().bootstrapFailedTotal.add(1);
const payload: SiteBootstrapFailedEvent = {
organizationId: organization.id,
message,
timestamp: new Date(),
};
eventBus.emit("site:bootstrap:failed", payload);
ctx.logger.error?.("site plugin createDefaultSite failed", {
organizationId: organization.id,
message,
});
}
}
);
}
bind(siteService, domainResolver, unsubscribe);
eventBus.emit("site:initialized", undefined);
}
async function runOnReady(
ctx: PluginContext<SitePluginConfig>,
config: SitePluginConfig,
siteService: SiteService,
domainResolver: DomainResolverService
): Promise<void> {
const app = ctx.getService("app");
if (app === undefined) {
throw new SiteError(
SITE_CODES.SERVICE_NOT_AVAILABLE,
"site plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const existing = (
app as unknown as { routes?: ReadonlyArray<{ path: string }> }
).routes;
if (
Array.isArray(existing) &&
existing.some((r) => r.path.startsWith(`${config.routePrefix}/sites/`))
) {
throw new SiteError(
SITE_CODES.ROUTE_PREFIX_COLLISION,
`Route prefix '${config.routePrefix}' already exposes /sites/* routes`,
{
context: {
prefix: config.routePrefix,
statusCode: 500,
},
}
);
}
const publicRoutes = createPublicSiteRoutes({
siteService,
domainResolver,
});
const privateRoutes = createPrivateSiteRoutes({ siteService });
if (config.enableDomainResolution) {
app.route(config.routePrefix, publicRoutes);
}
app.route(config.routePrefix, privateRoutes);
const eventBus = ctx.eventBus as unknown as EventBusLike<SiteEventMap>;
eventBus.emit("site:ready", undefined);
}
function mustHandles(
svc: SiteService | undefined,
resolver: DomainResolverService | undefined
): { svc: SiteService; resolver: DomainResolverService } {
if (svc === undefined || resolver === undefined) {
throw new SiteError(
SITE_CODES.SERVICE_NOT_AVAILABLE,
"site plugin onReady ran before onInit constructed services",
{ context: { statusCode: 500 } }
);
}
return { svc, resolver };
}

View File

@@ -0,0 +1,53 @@
# `routes/private-routes.ts`
## Purpose
Builds the authenticated Hono route tree for site CRUD and domain management. The factory is intentionally auth-agnostic — the suaveplan convention is that the host application composes auth middleware (`requireAuth()` from `@suaveplan/api-plugin-auth` or any equivalent) at the prefix where this router is mounted. Decoupling auth from the route factory keeps the package usable by any consumer (e.g. a future GraphQL gateway, an admin CLI) without forcing a hard dependency on a specific auth plugin.
## Features
- `POST /sites` — create.
- `PATCH /sites/{id}` — update name / settings.
- `POST /sites/{id}/domains` — add domain.
- `DELETE /sites/{id}/domains` — remove domain.
- OpenAPI schemas embedded inline via `@hono/zod-openapi` so the generated spec is the contract.
- Errors propagate as `SiteError` (caught and rendered by the host's global error middleware).
## Usage: Basic
```ts
import { createPrivateSiteRoutes } from "@suaveplan/api-plugin-site/routes/private-routes";
const privateRouter = createPrivateSiteRoutes({ siteService });
app.use("/api/v1", requireAuth());
app.route("/api/v1", privateRouter);
```
## Usage: Advanced
```ts
// Layer additional rate-limit + audit middleware on top.
const privateRouter = createPrivateSiteRoutes({ siteService });
app.use("/admin", requireAuth(), rateLimit({ key: "site-mutations" }));
app.route("/admin", privateRouter);
```
## API Reference
- `interface PrivateSiteRoutesDeps` — `siteService`.
- `createPrivateSiteRoutes(deps: PrivateSiteRoutesDeps): OpenAPIHono`.
## Recommended middleware
Write routes benefit most from layered rate-limit + audit middleware. The site plugin ships the `MIDDLEWARE_RECOMMENDATIONS` constant (see `src/integration/middleware-recommendations.md`) describing the recommended ceiling and audit category for this group:
- **Rate-limit**: `30 req / 60s` on the `/sites/*` path pattern (`private:write` group). Writes are rare per tenant and warrant the tightest ceiling; this protects the database from runaway clients without inconveniencing legitimate operators.
- **Audit category**: `data.mutation`. Every successful or failed write produces one `AuditEvent` in the host's configured `AuditStorageAdapter`. Combine with `enableChainIntegrity: true` for a tamper-evident trail.
The recommended layering order — audit OUTERMOST, rate-limit MIDDLE, site routes INNERMOST — is documented end-to-end in `docs/middleware-integration.md` and proven by `src/integration/middleware-composition.integration.test.ts`. The site plugin does not bind either middleware itself; composition stays in the host via the Hono `app.use(prefix, middleware)` pattern.
## Implementation Notes
- The factory accepts a single deps object so future additions (audit, event bus) stay non-breaking.
- All bodies are Zod-validated via `@hono/zod-openapi` — invalid input is rejected before reaching the service.
- The "remove domain" endpoint uses a body (not a path segment) so callers can submit the canonical-cased domain string and so the operation can be batched in future without an additional route shape.

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { mergeConfig } from "../schemas/options.js";
import type { SiteDb } from "../service/db-port.js";
import { SiteService } from "../service/site.js";
import { createPrivateSiteRoutes } from "./private-routes.js";
function memoryDb(): SiteDb {
const sites = new Map<string, Record<string, unknown>>();
let counter = 0;
return {
query: async (sql, params) => {
const text = sql.replace(/\s+/g, " ").trim();
if (text.startsWith("SELECT * FROM site WHERE id =")) {
const id = params?.[0] as string;
const row = sites.get(id);
return (row ? [row] : []) as never;
}
if (
text.startsWith(
"SELECT count() as count FROM site WHERE organization_id"
)
) {
return [{ count: 0 }] as never;
}
return [] as never;
},
create: async (_t, d) => {
counter += 1;
const id = `site:${counter}`;
const row = { ...(d as object), id };
sites.set(id, row as Record<string, unknown>);
return row as never;
},
update: async (_t, id, patch) => {
const row = sites.get(id);
if (!row) return null;
const merged = { ...row, ...(patch as Record<string, unknown>) };
sites.set(id, merged);
return merged as never;
},
};
}
describe("createPrivateSiteRoutes", () => {
it("creates a site via POST /sites", { timeout: 5000 }, async () => {
const db = memoryDb();
const svc = new SiteService(db, mergeConfig());
const app = createPrivateSiteRoutes({ siteService: svc });
const r = await app.request("/sites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
organization_id: "organization:1",
name: "Acme",
domains: ["acme.example.com"],
}),
});
expect(r.status).toBe(201);
const body = (await r.json()) as { id: string; name: string };
expect(body.name).toBe("Acme");
});
it("rejects POST /sites with bad input via Zod 400", {
timeout: 5000,
}, async () => {
const db = memoryDb();
const svc = new SiteService(db, mergeConfig());
const app = createPrivateSiteRoutes({ siteService: svc });
const r = await app.request("/sites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(r.status >= 400).toBe(true);
});
it("updates a site via PATCH /sites/{id}", {
timeout: 5000,
}, async () => {
const db = memoryDb();
const svc = new SiteService(db, mergeConfig());
const app = createPrivateSiteRoutes({ siteService: svc });
const created = await svc.create({
organization_id: "organization:1",
name: "Acme",
domains: ["acme.example.com"],
});
const r = await app.request(`/sites/${created.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Acme 2" }),
});
expect(r.status).toBe(200);
const body = (await r.json()) as { name: string };
expect(body.name).toBe("Acme 2");
});
it("adds and removes a domain", { timeout: 5000 }, async () => {
const db = memoryDb();
const svc = new SiteService(db, mergeConfig());
const app = createPrivateSiteRoutes({ siteService: svc });
const created = await svc.create({
organization_id: "organization:1",
name: "Acme",
domains: ["acme.example.com"],
});
const addRes = await app.request(`/sites/${created.id}/domains`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ domain: "new.example.com" }),
});
expect(addRes.status).toBe(200);
const addBody = (await addRes.json()) as { domains: string[] };
expect(addBody.domains).toContain("new.example.com");
const removeRes = await app.request(`/sites/${created.id}/domains`, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ domain: "new.example.com" }),
});
expect(removeRes.status).toBe(200);
const removeBody = (await removeRes.json()) as { domains: string[] };
expect(removeBody.domains).not.toContain("new.example.com");
});
});

View File

@@ -0,0 +1,155 @@
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import {
AddDomainRequestSchema,
AddDomainResponseSchema,
RemoveDomainRequestSchema,
RemoveDomainResponseSchema,
} from "../schemas/domain.js";
import {
CreateSiteSchema,
SiteIdParamSchema,
SiteSchema,
UpdateSiteSchema,
} from "../schemas/site.js";
import type { SiteService } from "../service/site.js";
/** Inputs for {@link createPrivateSiteRoutes}. */
export interface PrivateSiteRoutesDeps {
siteService: SiteService;
}
/**
* Build the authenticated Hono router exposing the full CRUD surface
* for site records and their domain lists. Callers are expected to
* have already installed an authentication middleware upstream — this
* factory does NOT gate on auth itself so the genesis app composition
* remains the canonical place to mount auth.
*
* Routes:
* - `POST /sites`
* - `PATCH /sites/{id}`
* - `POST /sites/{id}/domains`
* - `DELETE /sites/{id}/domains`
*/
export function createPrivateSiteRoutes(
deps: PrivateSiteRoutesDeps
): OpenAPIHono {
const app = new OpenAPIHono();
const { siteService } = deps;
const createSiteRoute = createRoute({
method: "post",
path: "/sites",
tags: ["Sites (Private)"],
summary: "Create a new site",
request: {
body: {
content: {
"application/json": { schema: CreateSiteSchema },
},
},
},
responses: {
201: {
content: {
"application/json": { schema: SiteSchema },
},
description: "Site created",
},
},
});
app.openapi(createSiteRoute, async (c) => {
const body = c.req.valid("json");
const site = await siteService.create(body);
return c.json(site, 201);
});
const updateRoute = createRoute({
method: "patch",
path: "/sites/{id}",
tags: ["Sites (Private)"],
summary: "Update site",
request: {
params: SiteIdParamSchema,
body: {
content: {
"application/json": { schema: UpdateSiteSchema },
},
},
},
responses: {
200: {
content: {
"application/json": { schema: SiteSchema },
},
description: "Site updated",
},
},
});
app.openapi(updateRoute, async (c) => {
const { id } = c.req.valid("param");
const body = c.req.valid("json");
const site = await siteService.update(id, body);
return c.json(site, 200);
});
const addDomainRoute = createRoute({
method: "post",
path: "/sites/{id}/domains",
tags: ["Sites (Private)"],
summary: "Add a domain to a site",
request: {
params: SiteIdParamSchema,
body: {
content: {
"application/json": { schema: AddDomainRequestSchema },
},
},
},
responses: {
200: {
content: {
"application/json": { schema: AddDomainResponseSchema },
},
description: "Domain added",
},
},
});
app.openapi(addDomainRoute, async (c) => {
const { id } = c.req.valid("param");
const { domain } = c.req.valid("json");
const site = await siteService.addDomain(id, domain);
return c.json({ success: true, domains: site.domains }, 200);
});
const removeDomainRoute = createRoute({
method: "delete",
path: "/sites/{id}/domains",
tags: ["Sites (Private)"],
summary: "Remove a domain from a site",
request: {
params: SiteIdParamSchema,
body: {
content: {
"application/json": { schema: RemoveDomainRequestSchema },
},
},
},
responses: {
200: {
content: {
"application/json": { schema: RemoveDomainResponseSchema },
},
description: "Domain removed",
},
},
});
app.openapi(removeDomainRoute, async (c) => {
const { id } = c.req.valid("param");
const { domain } = c.req.valid("json");
const site = await siteService.removeDomain(id, domain);
return c.json({ success: true, domains: site.domains }, 200);
});
return app;
}

View File

@@ -0,0 +1,43 @@
# `routes/public-routes.ts`
## Purpose
Builds the public (no-auth) Hono route tree that the plugin mounts under the configured `routePrefix`. The two endpoints exposed here are the entrypoints used by edge / SSR consumers that haven't yet authenticated — domain resolution drives multi-tenant routing decisions at request ingress, and the public-info endpoint surfaces a santised view of the site (theme + feature flags) so client bundles can render branded shells without leaking push config or secrets.
Routes are wired through `@hono/zod-openapi` so the OpenAPI document the host app exposes is automatically populated from the same Zod schemas the service layer validates against.
## Features
- `GET /sites/resolve/{domain}` — resolves a domain to a site + organization envelope.
- `GET /sites/{id}/public` — returns the `SitePublicInfo` projection.
- Both routes leverage the canonical `SiteSchema` / `SitePublicInfoSchema` for OpenAPI typing.
- Failures bubble up as `SiteError` so the host-app error middleware can map them to RFC 7807 / structured envelopes consistently with other plugins.
## Usage: Basic
```ts
import { createPublicSiteRoutes } from "@suaveplan/api-plugin-site/routes/public-routes";
const publicRouter = createPublicSiteRoutes({ siteService, domainResolver });
app.route("/api", publicRouter);
```
## Usage: Advanced
```ts
// Mount under a custom prefix and layer additional middleware.
const publicRouter = createPublicSiteRoutes({ siteService, domainResolver });
app.use("/v1", rateLimitMiddleware);
app.route("/v1", publicRouter);
```
## API Reference
- `interface PublicSiteRoutesDeps` — `siteService`, `domainResolver`.
- `createPublicSiteRoutes(deps: PublicSiteRoutesDeps): OpenAPIHono`.
## Implementation Notes
- The resolve route always returns 200 with `success: false` on miss — this mirrors the upstream contract so an existing consumer's redirect logic does not have to swap between 200 and 404.
- The public-info route DOES throw `SITE.SITE_NOT_FOUND` on miss, because the route is the integration boundary between "lookup" and "render" — a 404 here is the conventional signal to the upstream caller.
- The conditional spread (`...(site.slug !== undefined && {...})`) keeps the JSON payload tight by omitting absent optionals rather than emitting `null`.

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { mergeConfig } from "../schemas/options.js";
import type { SiteDb } from "../service/db-port.js";
import { DomainResolverService } from "../service/domain-resolver.js";
import { SiteService } from "../service/site.js";
import { createPublicSiteRoutes } from "./public-routes.js";
function makeDb(siteRow?: unknown): SiteDb {
return {
query: async () => (siteRow ? [siteRow] : []) as never,
create: async (_t, d) => ({ ...d, id: "site:1" }) as never,
update: async () => null,
};
}
const siteRow = {
id: "site:1",
organization_id: "organization:1",
name: "Acme",
domains: ["example.com"],
settings: {
maintenance_mode: false,
theme: {
primary_color: "#111",
secondary_color: "#222",
font_family: "",
},
features: ["beta"],
},
state: 1,
created_at: "2026-05-19T00:00:00Z",
updated_at: "2026-05-19T00:00:00Z",
};
const resolveRow = {
site: siteRow,
organization: { id: "organization:1", name: "Acme", slug: "acme" },
};
describe("createPublicSiteRoutes", () => {
it("resolves a domain to a site envelope", { timeout: 5000 }, async () => {
const db = makeDb(resolveRow);
const svc = new SiteService(db, mergeConfig());
const resolver = new DomainResolverService(db);
const app = createPublicSiteRoutes({
siteService: svc,
domainResolver: resolver,
});
const r = await app.request("/sites/resolve/example.com");
expect(r.status).toBe(200);
const body = (await r.json()) as {
success: boolean;
site?: { id: string };
};
expect(body.success).toBe(true);
expect(body.site?.id).toBe("site:1");
});
it("returns success:false envelope on a miss", {
timeout: 5000,
}, async () => {
const db = makeDb();
const svc = new SiteService(db, mergeConfig());
const resolver = new DomainResolverService(db);
const app = createPublicSiteRoutes({
siteService: svc,
domainResolver: resolver,
});
const r = await app.request("/sites/resolve/missing.example");
expect(r.status).toBe(200);
const body = (await r.json()) as { success: boolean; error?: string };
expect(body.success).toBe(false);
expect(body.error).toBe("Domain not found");
});
it("public-info embeds slug when present", { timeout: 5000 }, async () => {
const slugRow = { ...siteRow, slug: "acme-marketing" };
const db = makeDb(slugRow);
const svc = new SiteService(db, mergeConfig());
const resolver = new DomainResolverService(db);
const app = createPublicSiteRoutes({
siteService: svc,
domainResolver: resolver,
});
const r = await app.request("/sites/site:1/public");
const body = (await r.json()) as { slug?: string };
expect(body.slug).toBe("acme-marketing");
});
it("returns public-site projection (no push secrets)", {
timeout: 5000,
}, async () => {
const db = makeDb(siteRow);
const svc = new SiteService(db, mergeConfig());
const resolver = new DomainResolverService(db);
const app = createPublicSiteRoutes({
siteService: svc,
domainResolver: resolver,
});
const r = await app.request("/sites/site:1/public");
expect(r.status).toBe(200);
const body = (await r.json()) as {
settings: { features: string[]; theme: { primary_color: string } };
};
expect(body.settings.features).toEqual(["beta"]);
expect(body.settings.theme.primary_color).toBe("#111");
});
it("throws SiteError when public-info site is missing (host-level catch)", {
timeout: 5000,
}, async () => {
const db = makeDb();
const svc = new SiteService(db, mergeConfig());
const resolver = new DomainResolverService(db);
const app = createPublicSiteRoutes({
siteService: svc,
domainResolver: resolver,
});
// The route throws SiteError; the Hono app will surface it as an
// unhandled rejection -> 500 unless the host installs an error
// middleware. Here we directly invoke the request and assert the
// status is non-2xx.
const r = await app.request("/sites/site:absent/public");
expect(r.status >= 400).toBe(true);
});
});

View File

@@ -0,0 +1,120 @@
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { SITE_CODES, SiteError } from "../error-codes/error-codes.js";
import {
DomainResolveParamSchema,
DomainResolveResponseSchema,
} from "../schemas/domain.js";
import { SiteIdParamSchema, SitePublicInfoSchema } from "../schemas/site.js";
import type { DomainResolverService } from "../service/domain-resolver.js";
import type { SiteService } from "../service/site.js";
/** Inputs for {@link createPublicSiteRoutes}. */
export interface PublicSiteRoutesDeps {
siteService: SiteService;
domainResolver: DomainResolverService;
}
/**
* Build the public (no-auth) Hono router exposing:
*
* - `GET /sites/resolve/{domain}` — resolve a domain to a site +
* organization summary.
* - `GET /sites/{id}/public` — return the public projection of a site
* (no secrets / push config).
*
* Both routes return `SiteError`-mapped JSON envelopes on failure.
*/
export function createPublicSiteRoutes(
deps: PublicSiteRoutesDeps
): OpenAPIHono {
const app = new OpenAPIHono();
const { siteService, domainResolver } = deps;
const resolveRoute = createRoute({
method: "get",
path: "/sites/resolve/{domain}",
tags: ["Sites (Public)"],
summary: "Resolve domain to site + organization",
request: { params: DomainResolveParamSchema },
responses: {
200: {
content: {
"application/json": {
schema: DomainResolveResponseSchema,
},
},
description: "Domain resolved or not-found envelope",
},
},
});
app.openapi(resolveRoute, async (c) => {
const { domain } = c.req.valid("param");
const result = await domainResolver.resolve(domain);
return c.json(
{
success: result.success,
...(result.site !== undefined && { site: result.site }),
...(result.organization !== undefined && {
organization: result.organization,
}),
...(result.error !== undefined && { error: result.error }),
},
200
);
});
const publicInfoRoute = createRoute({
method: "get",
path: "/sites/{id}/public",
tags: ["Sites (Public)"],
summary: "Public site information",
request: { params: SiteIdParamSchema },
responses: {
200: {
content: {
"application/json": {
schema: SitePublicInfoSchema,
},
},
description: "Public site info",
},
404: {
content: {
"application/json": {
schema: SitePublicInfoSchema.partial(),
},
},
description: "Site not found",
},
},
});
app.openapi(publicInfoRoute, async (c) => {
const { id } = c.req.valid("param");
const site = await siteService.getById(id);
if (!site) {
throw new SiteError(
SITE_CODES.SITE_NOT_FOUND,
`Site '${id}' not found`,
{ context: { statusCode: 404 } }
);
}
return c.json(
{
id: site.id,
name: site.name,
...(site.slug !== undefined && { slug: site.slug }),
domains: site.domains,
settings: {
theme: site.settings.theme,
features: site.settings.features,
},
state: site.state,
},
200
);
});
return app;
}

View File

@@ -0,0 +1,48 @@
# `schemas/domain.ts`
## Purpose
Zod schemas for every domain-shaped value: the validated domain literal itself, the request/response shapes for `addDomain` and `removeDomain`, and the resolution param + response that the public route layer consumes. Lives in the package because the domain regex is the canonical authority — the SurrealDB migration's `fn::site::domain::valid` mirrors it, and the route layer drives parsing from this module so that bad input never reaches the service.
## Features
- `DomainSchema` — string in `1..253` chars matching a DNS-style regex, `localhost`, or an IPv4 literal.
- `AddDomainRequestSchema` / `AddDomainResponseSchema`.
- `RemoveDomainRequestSchema` / `RemoveDomainResponseSchema`.
- `DomainResolveParamSchema` — `:domain` URL param.
- `DomainResolveResponseSchema` — embeds the full `SiteSchema` shape on success.
## Usage: Basic
```ts
import { DomainSchema } from "@suaveplan/api-plugin-site/schemas/domain";
const parsed = DomainSchema.parse("acme.example.com");
```
## Usage: Advanced
```ts
import {
AddDomainRequestSchema,
DomainResolveResponseSchema,
} from "@suaveplan/api-plugin-site/schemas/domain";
const body = AddDomainRequestSchema.parse(await c.req.json());
const resolved = DomainResolveResponseSchema.parse(serviceResult);
return c.json(resolved);
```
## API Reference
- `DomainSchema: ZodString` — `min(1).max(253).regex(...)`.
- `AddDomainRequestSchema / AddDomainResponseSchema`.
- `RemoveDomainRequestSchema / RemoveDomainResponseSchema`.
- `DomainResolveParamSchema / DomainResolveResponseSchema`.
- Inferred types: `AddDomainRequest`, `AddDomainResponse`, `RemoveDomainRequest`, `RemoveDomainResponse`, `DomainResolveParam`, `DomainResolveResponse`.
## Implementation Notes
- The DOMAIN regex accepts standard DNS names (with the conservative 63-char label cap), `localhost`, and dotted-decimal IPv4. IPv6 literals and internationalised (`xn--`) host names are accepted via the standard DNS branch when normalised; raw unicode is rejected — callers should pre-normalise via the punycode adapter when needed.
- The regex's `i` flag makes the rule case-insensitive; `DomainResolverService.normalizeDomain` lowercases inputs before any comparison so storage and lookup paths agree.
- Length cap matches RFC 1035 (253 octets without the trailing dot).

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AddDomainRequestSchema,
DomainResolveResponseSchema,
DomainSchema,
} from "./domain.js";
describe("DomainSchema", () => {
it("accepts standard DNS hosts", { timeout: 5000 }, () => {
expect(DomainSchema.parse("acme.example.com")).toBe("acme.example.com");
expect(DomainSchema.parse("sub.domain.co.uk")).toBe("sub.domain.co.uk");
});
it("accepts localhost", { timeout: 5000 }, () => {
expect(DomainSchema.parse("localhost")).toBe("localhost");
});
it("accepts IPv4 literal", { timeout: 5000 }, () => {
expect(DomainSchema.parse("127.0.0.1")).toBe("127.0.0.1");
});
it("rejects whitespace-bearing values", { timeout: 5000 }, () => {
const r = DomainSchema.safeParse(" acme.example.com");
expect(r.success).toBe(false);
});
it("rejects empty string", { timeout: 5000 }, () => {
const r = DomainSchema.safeParse("");
expect(r.success).toBe(false);
});
it("rejects values over 253 chars", { timeout: 5000 }, () => {
const huge = `${"a".repeat(250)}.com`;
const r = DomainSchema.safeParse(huge);
expect(r.success).toBe(false);
});
});
describe("AddDomainRequestSchema", () => {
it("requires a valid domain", { timeout: 5000 }, () => {
const r = AddDomainRequestSchema.safeParse({ domain: "" });
expect(r.success).toBe(false);
});
it("accepts a valid payload", { timeout: 5000 }, () => {
const r = AddDomainRequestSchema.parse({ domain: "acme.example.com" });
expect(r.domain).toBe("acme.example.com");
});
});
describe("DomainResolveResponseSchema", () => {
it("accepts a miss envelope", { timeout: 5000 }, () => {
const r = DomainResolveResponseSchema.parse({
success: false,
error: "Domain not found",
});
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,68 @@
import z from "zod";
import { RecordIdSchema, SiteSchema } from "./site.js";
/**
* Domain regex: standard DNS name, `localhost`, IPv4 literal.
*
* Mirrors the upstream `domain.schema.ts` regex — see `domain.md`
* for the full grammar table.
*/
const DOMAIN_REGEX =
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^localhost$|^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/i;
/** Domain literal schema (1..253 chars, regex-validated). */
export const DomainSchema = z
.string()
.min(1)
.max(253)
.regex(DOMAIN_REGEX, "Invalid domain format");
/** `POST /sites/:id/domains` body. */
export const AddDomainRequestSchema = z.object({
domain: DomainSchema,
});
/** Response from `POST /sites/:id/domains`. */
export const AddDomainResponseSchema = z.object({
success: z.boolean(),
domains: z.array(z.string()),
error: z.string().optional(),
});
/** `DELETE /sites/:id/domains` body. */
export const RemoveDomainRequestSchema = z.object({
domain: DomainSchema,
});
/** Response from `DELETE /sites/:id/domains`. */
export const RemoveDomainResponseSchema = z.object({
success: z.boolean(),
domains: z.array(z.string()),
error: z.string().optional(),
});
/** `GET /sites/resolve/:domain` path param. */
export const DomainResolveParamSchema = z.object({
domain: DomainSchema,
});
/** Response from `GET /sites/resolve/:domain`. */
export const DomainResolveResponseSchema = z.object({
success: z.boolean(),
site: SiteSchema.optional(),
organization: z
.object({
id: RecordIdSchema,
name: z.string(),
slug: z.string(),
})
.optional(),
error: z.string().optional(),
});
export type AddDomainRequest = z.infer<typeof AddDomainRequestSchema>;
export type AddDomainResponse = z.infer<typeof AddDomainResponseSchema>;
export type RemoveDomainRequest = z.infer<typeof RemoveDomainRequestSchema>;
export type RemoveDomainResponse = z.infer<typeof RemoveDomainResponseSchema>;
export type DomainResolveParam = z.infer<typeof DomainResolveParamSchema>;
export type DomainResolveResponse = z.infer<typeof DomainResolveResponseSchema>;

View File

@@ -0,0 +1,54 @@
# `schemas/options.ts`
## Purpose
Owns the plugin-options schema and the `mergeConfig` / `DEFAULT_CONFIG` helpers used by `createSitePlugin(...)`. Every option is validated at the boundary so misconfigurations are caught at plugin construction time — not at first request — and consumers get a fully-typed resolved config rather than a `Partial<...>` they have to defend against. `DEFAULT_CONFIG` is `Object.freeze`'d so accidental mutation by an integrator can't poison the defaults for the next call.
## Features
- `SitePluginOptionsSchema` — Zod schema with sensible defaults for every key.
- `SitePluginConfig` — resolved type after defaults are applied.
- `SitePluginOptionsInput` — caller-input type (every key optional).
- `DEFAULT_CONFIG` — frozen snapshot of the parsed empty input.
- `mergeConfig(user?)` — pure merge helper that parses through Zod, never mutates defaults.
## Usage: Basic
```ts
import { mergeConfig } from "@suaveplan/api-plugin-site/schemas/options";
const config = mergeConfig({ maxSitesPerOrg: 25 });
```
## Usage: Advanced
```ts
import {
DEFAULT_CONFIG,
SitePluginOptionsSchema,
} from "@suaveplan/api-plugin-site/schemas/options";
const parsed = SitePluginOptionsSchema.safeParse(rawInput);
if (!parsed.success) {
throw new SiteError(SITE_CODES.INVALID_OPTIONS, "bad config", {
cause: parsed.error,
});
}
// Discoverability:
console.log("default cap:", DEFAULT_CONFIG.maxSitesPerOrg);
```
## API Reference
- `SitePluginOptionsSchema: ZodObject` — defaulted shape.
- `SitePluginOptionsInput`, `SitePluginConfig` — inferred input/output types.
- `DEFAULT_CONFIG: Readonly<SitePluginConfig>` — `Object.freeze`'d snapshot.
- `mergeConfig(user?: SitePluginOptionsInput): SitePluginConfig`.
## Implementation Notes
- Defaults match the upstream `DEFAULT_CONFIG` literal so consumer behaviour is byte-identical for the common defaults.
- `routePrefix` defaults to `/api` to match other suaveplan plugins.
- `mergeConfig` always returns a fresh object; tests assert that mutating its result never affects `DEFAULT_CONFIG` (see `options.test.ts`).
- Negative or zero `maxSitesPerOrg` / `maxDomainsPerSite` are rejected by `.positive()`; production sites should size these via env-driven config.

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DEFAULT_CONFIG,
mergeConfig,
SitePluginOptionsSchema,
} from "./options.js";
describe("SitePluginOptionsSchema", () => {
it("applies all defaults when called with empty input", {
timeout: 5000,
}, () => {
const parsed = SitePluginOptionsSchema.parse({});
expect(parsed.maxSitesPerOrg).toBe(10);
expect(parsed.maxDomainsPerSite).toBe(5);
expect(parsed.autoCreateDefaultSite).toBe(true);
expect(parsed.defaultDomainPattern).toBe("{slug}.suaveplan.app");
expect(parsed.enableDomainResolution).toBe(true);
expect(parsed.enablePushNotifications).toBe(true);
expect(parsed.enableSiteSearch).toBe(true);
expect(parsed.routePrefix).toBe("/api");
});
it("rejects non-positive numeric caps", { timeout: 5000 }, () => {
const r = SitePluginOptionsSchema.safeParse({ maxSitesPerOrg: 0 });
expect(r.success).toBe(false);
});
it("rejects empty defaultDomainPattern", { timeout: 5000 }, () => {
const r = SitePluginOptionsSchema.safeParse({
defaultDomainPattern: "",
});
expect(r.success).toBe(false);
});
});
describe("DEFAULT_CONFIG", () => {
it("matches the documented defaults", { timeout: 5000 }, () => {
expect(DEFAULT_CONFIG.maxSitesPerOrg).toBe(10);
expect(DEFAULT_CONFIG.maxDomainsPerSite).toBe(5);
expect(DEFAULT_CONFIG.autoCreateDefaultSite).toBe(true);
expect(DEFAULT_CONFIG.defaultDomainPattern).toBe(
"{slug}.suaveplan.app"
);
});
it("is frozen so accidental mutation fails", { timeout: 5000 }, () => {
expect(Object.isFrozen(DEFAULT_CONFIG)).toBe(true);
});
});
describe("mergeConfig", () => {
it("returns defaults when called with no args", {
timeout: 5000,
}, () => {
const config = mergeConfig();
expect(config).toEqual(DEFAULT_CONFIG);
});
it("overrides single field, preserves the rest", {
timeout: 5000,
}, () => {
const config = mergeConfig({ maxSitesPerOrg: 25 });
expect(config.maxSitesPerOrg).toBe(25);
expect(config.maxDomainsPerSite).toBe(5);
});
it("does not mutate DEFAULT_CONFIG even if caller mutates the result", {
timeout: 5000,
}, () => {
const fresh = mergeConfig({ maxSitesPerOrg: 99 });
(fresh as { maxSitesPerOrg: number }).maxSitesPerOrg = 7;
expect(DEFAULT_CONFIG.maxSitesPerOrg).toBe(10);
});
it("merges multiple overrides at once", { timeout: 5000 }, () => {
const config = mergeConfig({
maxSitesPerOrg: 3,
maxDomainsPerSite: 2,
enableSiteSearch: false,
routePrefix: "/v1",
});
expect(config.maxSitesPerOrg).toBe(3);
expect(config.maxDomainsPerSite).toBe(2);
expect(config.enableSiteSearch).toBe(false);
expect(config.routePrefix).toBe("/v1");
});
});

View File

@@ -0,0 +1,46 @@
import z from "zod";
/**
* Options schema for `createSitePlugin(...)`. Validates user input at
* the boundary so misconfigurations fail fast with
* `SiteError(SITE.INVALID_OPTIONS)` carrying the `ZodError` as `cause`.
*/
export const SitePluginOptionsSchema = z.object({
/** Maximum sites per organization. */
maxSitesPerOrg: z.number().int().positive().default(10),
/** Maximum domains per site. */
maxDomainsPerSite: z.number().int().positive().default(5),
/** Auto-create a default site on `organization:created`. */
autoCreateDefaultSite: z.boolean().default(true),
/** Default domain pattern; `{slug}` is interpolated. */
defaultDomainPattern: z.string().min(1).default("{slug}.suaveplan.app"),
/** Toggle the public domain-resolution route. */
enableDomainResolution: z.boolean().default(true),
/** Persist push-notification settings on site records. */
enablePushNotifications: z.boolean().default(true),
/** Expose site-search query helper. */
enableSiteSearch: z.boolean().default(true),
/** Hono route prefix to mount the plugin routes under. */
routePrefix: z.string().default("/api"),
});
/** Caller-supplied options input (everything optional). */
export type SitePluginOptionsInput = z.input<typeof SitePluginOptionsSchema>;
/** Resolved options after defaults are applied. */
export type SitePluginConfig = z.output<typeof SitePluginOptionsSchema>;
/** Frozen `DEFAULT_CONFIG` snapshot. */
export const DEFAULT_CONFIG: Readonly<SitePluginConfig> = Object.freeze(
SitePluginOptionsSchema.parse({})
);
/**
* Merge a partial user config with the defaults. Returns a fresh
* object every call — never mutates `DEFAULT_CONFIG`.
*/
export function mergeConfig(
userConfig?: SitePluginOptionsInput
): SitePluginConfig {
return SitePluginOptionsSchema.parse({ ...userConfig });
}

View File

@@ -0,0 +1,56 @@
# `schemas/push.ts`
## Purpose
Schemas and a pure validator for the per-site push-notification configuration. The site plugin treats push config as opaque persisted data, but applications consuming a site's settings — APN gateways, FCM dispatchers, admin UIs — need a single, schema-validated shape so they can fail fast on misconfiguration. The TypeScript validator `validatePushConfig` mirrors the SurrealDB `fn::site::push::validate` semantics for parity between application-tier and database-tier checks.
## Features
- `ApplePushConfigSchema` — APN parameters: key id, team id, bundle id, key path, environment (`sandbox` | `production`).
- `FirebasePushConfigSchema` — FCM parameters: server key, project id.
- `PushConfigSchema` — composite shape; at least one provider required.
- `PushConfigValidationResponseSchema` — `{success, apple_valid, firebase_valid, errors}` envelope.
- `validatePushConfig(cfg)` — pure function returning the validation envelope without touching the database.
## Usage: Basic
```ts
import { ApplePushConfigSchema } from "@suaveplan/api-plugin-site/schemas/push";
const apple = ApplePushConfigSchema.parse({
key_id: "K123",
team_id: "T456",
bundle_id: "com.acme.app",
key_path: "/etc/apns/key.p8",
});
```
## Usage: Advanced
```ts
import {
PushConfigSchema,
validatePushConfig,
} from "@suaveplan/api-plugin-site/schemas/push";
const cfg = PushConfigSchema.parse(rawInput);
const report = validatePushConfig(cfg);
if (!report.apple_valid && !report.firebase_valid) {
throw new SiteError(SITE_CODES.VALIDATION, report.errors.join(", "));
}
```
## API Reference
- `ApplePushConfigSchema`, `FirebasePushConfigSchema`, `PushConfigSchema`.
- `PushConfigValidationResponseSchema`.
- `validatePushConfig(cfg: PushConfig | undefined): PushConfigValidationResponse` — pure validator.
- Inferred types: `ApplePushConfig`, `FirebasePushConfig`, `PushConfig`, `PushConfigValidationResponse`.
## Implementation Notes
- The composite refinement enforces "at least one provider"; consumers calling `validatePushConfig(undefined)` get `success: false` plus both error strings, mirroring the SurrealDB function exactly.
- Apple `environment` defaults to `"sandbox"` — production callers must opt-in. The SurrealDB schema mirrors this default.
- This file is pure ESM with zero runtime side-effects; tree-shakes cleanly when consumers do not enable push.
- `validatePushConfig` is intentionally separate from `PushConfigSchema.parse`. The Zod schema enforces the structural contract — types, lengths, the at-least-one refinement — and is used at the network boundary. The validator runs a finer-grained per-provider completeness check used by the site service when persisting state, so callers can write a partial Apple block and have the validator surface a clean "Apple push configuration incomplete" message instead of a generic Zod issue.
- The validator never throws — it always returns a structured envelope. This matches the SurrealDB function's contract and keeps the wire shape consistent across application-tier and database-tier callers.

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ApplePushConfigSchema,
FirebasePushConfigSchema,
PushConfigSchema,
validatePushConfig,
} from "./push.js";
describe("ApplePushConfigSchema", () => {
it("defaults environment to sandbox", { timeout: 5000 }, () => {
const parsed = ApplePushConfigSchema.parse({
key_id: "K",
team_id: "T",
bundle_id: "B",
key_path: "/p",
});
expect(parsed.environment).toBe("sandbox");
});
it("rejects missing required fields", { timeout: 5000 }, () => {
const r = ApplePushConfigSchema.safeParse({
key_id: "",
team_id: "",
bundle_id: "",
key_path: "",
});
expect(r.success).toBe(false);
});
});
describe("FirebasePushConfigSchema", () => {
it("requires both fields", { timeout: 5000 }, () => {
const r = FirebasePushConfigSchema.safeParse({
server_key: "",
project_id: "",
});
expect(r.success).toBe(false);
});
});
describe("PushConfigSchema", () => {
it("requires at least one provider", { timeout: 5000 }, () => {
const r = PushConfigSchema.safeParse({});
expect(r.success).toBe(false);
});
it("accepts firebase-only config", { timeout: 5000 }, () => {
const r = PushConfigSchema.parse({
firebase: { server_key: "k", project_id: "p" },
});
expect(r.firebase?.project_id).toBe("p");
});
});
describe("validatePushConfig", () => {
it("returns failure when no providers configured", {
timeout: 5000,
}, () => {
const r = validatePushConfig(undefined);
expect(r.success).toBe(false);
expect(r.apple_valid).toBe(false);
expect(r.firebase_valid).toBe(false);
expect(r.errors.length).toBe(2);
});
it("returns success when apple is complete", { timeout: 5000 }, () => {
const r = validatePushConfig({
apple: {
key_id: "K",
team_id: "T",
bundle_id: "B",
key_path: "/p",
environment: "sandbox",
},
});
expect(r.success).toBe(true);
expect(r.apple_valid).toBe(true);
expect(r.firebase_valid).toBe(false);
});
it("returns success when firebase is complete", { timeout: 5000 }, () => {
const r = validatePushConfig({
firebase: { server_key: "k", project_id: "p" },
});
expect(r.firebase_valid).toBe(true);
expect(r.success).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
import z from "zod";
/** Apple APN configuration schema. */
export const ApplePushConfigSchema = z.object({
key_id: z.string().min(1).describe("Apple Developer Key ID"),
team_id: z.string().min(1).describe("Apple Team ID"),
bundle_id: z.string().min(1).describe("App Bundle ID"),
key_path: z.string().min(1).describe("Path to .p8 key file"),
environment: z
.enum(["sandbox", "production"])
.default("sandbox")
.describe("APN environment"),
});
/** Firebase Cloud Messaging configuration schema. */
export const FirebasePushConfigSchema = z.object({
server_key: z.string().min(1).describe("Firebase Server Key"),
project_id: z.string().min(1).describe("Firebase Project ID"),
});
/**
* Composite push-notification config. At least one of `apple` or
* `firebase` MUST be present.
*/
export const PushConfigSchema = z
.object({
apple: ApplePushConfigSchema.optional(),
firebase: FirebasePushConfigSchema.optional(),
})
.refine((d) => d.apple !== undefined || d.firebase !== undefined, {
message:
"At least one push provider (Apple or Firebase) must be configured",
});
/** Validation report returned by `validatePushConfig`. */
export const PushConfigValidationResponseSchema = z.object({
success: z.boolean(),
apple_valid: z.boolean(),
firebase_valid: z.boolean(),
errors: z.array(z.string()),
});
export type ApplePushConfig = z.infer<typeof ApplePushConfigSchema>;
export type FirebasePushConfig = z.infer<typeof FirebasePushConfigSchema>;
export type PushConfig = z.infer<typeof PushConfigSchema>;
export type PushConfigValidationResponse = z.infer<
typeof PushConfigValidationResponseSchema
>;
/**
* Validate a {@link PushConfig} object and report which providers are
* complete. Mirrors the SurrealDB `fn::site::push::validate` function so
* application code can validate config before persisting.
*/
export function validatePushConfig(
cfg: PushConfig | undefined
): PushConfigValidationResponse {
const errors: string[] = [];
const apple = cfg?.apple;
const firebase = cfg?.firebase;
const apple_valid =
apple !== undefined &&
apple.key_id.length > 0 &&
apple.team_id.length > 0 &&
apple.bundle_id.length > 0 &&
apple.key_path.length > 0;
if (!apple_valid) {
errors.push("Apple push configuration incomplete");
}
const firebase_valid =
firebase !== undefined &&
firebase.server_key.length > 0 &&
firebase.project_id.length > 0;
if (!firebase_valid) {
errors.push("Firebase push configuration incomplete");
}
return {
success: apple_valid || firebase_valid,
apple_valid,
firebase_valid,
errors,
};
}

View File

@@ -0,0 +1,69 @@
# `schemas/site.ts`
## Purpose
Defines the Zod schemas that describe the canonical `site` entity, its create/update inputs, the public-facing slice, and the `:id` URL-parameter shape. These schemas live inside `@suaveplan/api-plugin-site` rather than `@suaveplan/api-schemas` because (a) the upstream `api-schemas/site` surface has not yet been migrated to suaveplan, and (b) the site plugin is the canonical owner of the site shape — every other consumer reads it through the plugin's exports. Inlining keeps the contract auditable in one package and prevents drift between the schema and the SurrealDB migration.
## Features
- `SiteSchema` — full record shape with `id`, `organization_id`, `name`, `slug`, `domains`, `settings`, `state`, `created_at`, `updated_at`.
- `SiteSettingsSchema` — embedded settings: `maintenance_mode`, `theme`, `features`, optional `push` (Apple + Firebase).
- `SiteThemeSchema` — `primary_color` / `secondary_color` / `font_family` palette with default values matching the SurrealDB defaults.
- `SiteStateNumberSchema` — the four legal numeric states (`1 / 2 / 4 / 8`).
- `CreateSiteSchema` — strict input with min/max domain count.
- `UpdateSiteSchema` — strict partial shape; rejects unknown keys.
- `SitePublicInfoSchema` — sanitized slice returned by public routes (no secrets / push config).
- `SiteIdParamSchema` — `{ id }` path parameter wrapper.
- `RecordIdSchema` — non-empty string id placeholder matching SurrealDB's `record:rand` surface.
## Usage: Basic
```ts
import { CreateSiteSchema } from "@suaveplan/api-plugin-site/schemas/site";
const parsed = CreateSiteSchema.parse({
organization_id: "organization:abc",
name: "Acme Marketing",
domains: ["acme.example.com"],
});
```
## Usage: Advanced
```ts
import { SiteSchema, UpdateSiteSchema } from "@suaveplan/api-plugin-site/schemas/site";
const update = UpdateSiteSchema.safeParse({
name: "Acme — Renamed",
settings: { features: ["beta-cms"] },
});
if (!update.success) {
throw new SiteError(SITE_CODES.VALIDATION, "invalid update", {
cause: update.error,
});
}
const fresh = SiteSchema.parse(persistedRowFromSurreal);
```
## API Reference
- `RecordIdSchema: ZodString` — non-empty.
- `SiteStateNumberSchema: ZodUnion<[ZodLiteral<1|2|4|8>]>`.
- `SiteThemeSchema: ZodObject` — defaulted palette.
- `SiteSettingsSchema: ZodObject` — settings + push.
- `SiteSchema: ZodObject` — full record.
- `CreateSiteSchema: ZodObject` — input for `SiteService.create`.
- `UpdateSiteSchema: ZodObject (strict)` — input for `SiteService.update`.
- `SitePublicInfoSchema: ZodObject` — public projection.
- `SiteIdParamSchema: ZodObject` — `{ id }`.
- Inferred types: `Site`, `CreateSite`, `UpdateSite`, `SitePublicInfo`, `SiteIdParam`.
## Implementation Notes
- Zod default-import (`import z from "zod"`) per the repo's Bun-safe convention.
- Domain count is capped at 5 in `CreateSiteSchema` to match `maxDomainsPerSite`'s default; runtime checks in `SiteService` re-validate against the configured (possibly larger) cap.
- `RecordIdSchema` is intentionally narrow — full SurrealDB record-id parsing belongs in `@suaveplan/surrealdb`.
- `UpdateSiteSchema` is `.strict()` so callers can't smuggle through unknown keys; this prevents accidental writes to fields not in the update surface.
- Push config is optional — sites without push are valid.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CreateSiteSchema,
SiteSchema,
SiteStateNumberSchema,
UpdateSiteSchema,
} from "./site.js";
describe("CreateSiteSchema", () => {
it("accepts a minimal valid payload", { timeout: 5000 }, () => {
const parsed = CreateSiteSchema.parse({
organization_id: "organization:abc",
name: "Acme",
domains: ["acme.example.com"],
});
expect(parsed.name).toBe("Acme");
expect(parsed.domains.length).toBe(1);
});
it("rejects empty domain list", { timeout: 5000 }, () => {
const r = CreateSiteSchema.safeParse({
organization_id: "o:1",
name: "Acme",
domains: [],
});
expect(r.success).toBe(false);
});
it("rejects more than 5 domains", { timeout: 5000 }, () => {
const r = CreateSiteSchema.safeParse({
organization_id: "o:1",
name: "Acme",
domains: ["a", "b", "c", "d", "e", "f"],
});
expect(r.success).toBe(false);
});
it("rejects name >100 chars", { timeout: 5000 }, () => {
const r = CreateSiteSchema.safeParse({
organization_id: "o:1",
name: "x".repeat(101),
domains: ["a"],
});
expect(r.success).toBe(false);
});
});
describe("UpdateSiteSchema", () => {
it("rejects unknown keys via .strict()", { timeout: 5000 }, () => {
const r = UpdateSiteSchema.safeParse({
name: "new",
sneaky: "value",
} as Record<string, unknown>);
expect(r.success).toBe(false);
});
it("accepts partial settings", { timeout: 5000 }, () => {
const r = UpdateSiteSchema.safeParse({
settings: { features: ["beta"] },
});
expect(r.success).toBe(true);
});
});
describe("SiteStateNumberSchema", () => {
it("accepts the four legal states", { timeout: 5000 }, () => {
for (const s of [1, 2, 4, 8]) {
expect(SiteStateNumberSchema.parse(s)).toBe(s);
}
});
it("rejects state 3", { timeout: 5000 }, () => {
const r = SiteStateNumberSchema.safeParse(3);
expect(r.success).toBe(false);
});
});
describe("SiteSchema", () => {
it("parses a full persisted row", { timeout: 5000 }, () => {
const parsed = SiteSchema.parse({
id: "site:1",
organization_id: "organization:1",
name: "Acme",
domains: ["acme.example.com"],
settings: {
maintenance_mode: false,
theme: {
primary_color: "#111",
secondary_color: "#222",
font_family: "",
},
features: [],
},
state: 1,
created_at: "2026-05-19T00:00:00Z",
updated_at: "2026-05-19T00:00:00Z",
});
expect(parsed.id).toBe("site:1");
});
});

View File

@@ -0,0 +1,111 @@
import z from "zod";
import {
SITE_STATE_ACTIVE,
SITE_STATE_ARCHIVED,
SITE_STATE_MAINTENANCE,
SITE_STATE_SUSPENDED,
} from "../types/types.js";
/**
* Non-empty resource-id string (SurrealDB record id surface — `table:rand`).
* Matches the upstream `RecordIdSchema` shape without taking a dep on it.
*/
export const RecordIdSchema = z.string().min(1);
/** Enum of legal site-state numbers (bitwise constants). */
export const SiteStateNumberSchema = z.union([
z.literal(SITE_STATE_ACTIVE),
z.literal(SITE_STATE_MAINTENANCE),
z.literal(SITE_STATE_SUSPENDED),
z.literal(SITE_STATE_ARCHIVED),
]);
/** Theme palette schema. */
export const SiteThemeSchema = z.object({
primary_color: z.string().default("#000033"),
secondary_color: z.string().default("#000066"),
font_family: z.string().default(""),
});
const ApplePushConfigSchemaInternal = z.object({
key_id: z.string().min(1),
team_id: z.string().min(1),
bundle_id: z.string().min(1),
key_path: z.string().min(1),
environment: z.enum(["sandbox", "production"]).default("sandbox"),
});
const FirebasePushConfigSchemaInternal = z.object({
server_key: z.string().min(1),
project_id: z.string().min(1),
});
/** Settings persisted with a site. */
export const SiteSettingsSchema = z.object({
maintenance_mode: z.boolean().default(false),
theme: SiteThemeSchema.default({
primary_color: "#000033",
secondary_color: "#000066",
font_family: "",
}),
features: z.array(z.string()).default([]),
push: z
.object({
apple: ApplePushConfigSchemaInternal.optional(),
firebase: FirebasePushConfigSchemaInternal.optional(),
})
.optional(),
});
/** Full persisted-site shape. */
export const SiteSchema = z.object({
id: RecordIdSchema,
organization_id: RecordIdSchema,
name: z.string().min(1).max(100),
slug: z.string().optional(),
domains: z.array(z.string()).min(1),
settings: SiteSettingsSchema,
state: SiteStateNumberSchema.default(SITE_STATE_ACTIVE),
created_at: z.string(),
updated_at: z.string(),
});
/** Input to `SiteService.create`. */
export const CreateSiteSchema = z.object({
organization_id: RecordIdSchema,
name: z.string().min(1).max(100),
domains: z.array(z.string()).min(1).max(5),
settings: SiteSettingsSchema.partial().optional(),
});
/** Input to `SiteService.update` — strict to reject unknown keys. */
export const UpdateSiteSchema = z
.object({
name: z.string().min(1).max(100).optional(),
settings: SiteSettingsSchema.partial().optional(),
})
.strict();
/** Public-facing site slice. */
export const SitePublicInfoSchema = z.object({
id: RecordIdSchema,
name: z.string(),
slug: z.string().optional(),
domains: z.array(z.string()),
settings: z.object({
theme: SiteThemeSchema,
features: z.array(z.string()),
}),
state: SiteStateNumberSchema,
});
/** Path param: `:id`. */
export const SiteIdParamSchema = z.object({
id: RecordIdSchema,
});
export type Site = z.infer<typeof SiteSchema>;
export type CreateSite = z.infer<typeof CreateSiteSchema>;
export type UpdateSite = z.infer<typeof UpdateSiteSchema>;
export type SitePublicInfo = z.infer<typeof SitePublicInfoSchema>;
export type SiteIdParam = z.infer<typeof SiteIdParamSchema>;

View File

@@ -0,0 +1,48 @@
# `service/db-port.ts`
## Purpose
Declares `SiteDb`, the structural database port that `SiteService` and `DomainResolverService` consume. suaveplan's foundation `DatabaseAdapter` (`@suaveplan/db`) only owns `query` / `execute`, so the typed CRUD surface (`create`, `update`) we need for `site` records is sourced from `@suaveplan/surrealdb`'s `SurrealAdapter`. Centralising the port here lets the services depend on a single structural shape rather than `SurrealAdapter` directly, which keeps the unit tests free of testcontainer requirements and keeps the production wiring honest: any `SurrealAdapter` instance satisfies this shape by construction.
## Features
- One structural interface — `SiteDb` — exposing `query` (positional bindings), `create` (typed), `update` (typed).
- No runtime imports — pure type module.
- Lets tests supply minimal fakes without crafting a full `DatabaseAdapter`.
## Usage: Basic
```ts
import type { SiteDb } from "@suaveplan/api-plugin-site/service/db-port";
const fake: SiteDb = {
query: async () => [],
create: async (_t, d) => ({ ...d, id: "site:1" }),
update: async () => null,
};
```
## Usage: Advanced
```ts
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { SiteDb } from "@suaveplan/api-plugin-site/service/db-port";
// Production wiring — the live SurrealAdapter satisfies SiteDb structurally.
function wire(db: SurrealAdapter): SiteDb {
return db;
}
```
## API Reference
- `interface SiteDb` —
- `query<T>(sql, params?): Promise<T[]>` — positional binds (`$p0...`).
- `create<T>(table, data): Promise<T & { id: string }>` — typed create.
- `update<T>(table, id, data): Promise<T | null>` — typed merge.
## Implementation Notes
- `query` bindings are positional because the `DatabaseAdapter.query` signature is `(sql, params?: readonly unknown[])`. The site plugin's SurQL statements are written with `$p0`, `$p1`, ... accordingly.
- The port deliberately omits transaction / migrate / connect surface — services don't need it. Adding new methods to the port is a breaking change and follows the OpenSpec workflow.
- The port is structural — adopters do not need to extend or implement an interface explicitly.

View File

@@ -0,0 +1,47 @@
/**
* Structural database port used by `SiteService` and
* `DomainResolverService`.
*
* The site plugin depends on the canonical `@suaveplan/db`
* `DatabaseAdapter.query` surface only — positional-binding strings via
* `$p0`, `$p1`, ... — plus the typed CRUD subset
* (`create` / `update`) exposed by `@suaveplan/surrealdb`'s
* `SurrealAdapter`. The genesis `DatabaseAdapter` interface owns only
* `query` / `execute`, so we declare the typed-CRUD surface here as a
* structural extension that real `SurrealAdapter` instances satisfy.
*
* Defining the port here decouples production wiring (which goes
* through `ServiceRegistry["db"]` typed as `SurrealAdapter`) from tests
* (which can supply any object conforming to this shape). No casts at
* the call site — the service constructor accepts this port directly.
*/
export interface SiteDb {
/**
* Execute a positional-binding SurQL statement. Inputs are bound as
* `$p0`, `$p1`, ... via `paramsToRecord` in the SurrealAdapter
* adapter; consumers of this port write the SurQL accordingly.
*/
query<T = Record<string, unknown>>(
sql: string,
params?: readonly unknown[]
): Promise<T[]>;
/**
* Typed CRUD create — `SurrealAdapter.create`. Returns the created
* record with its assigned id.
*/
create<T extends Record<string, unknown>>(
table: string,
data: T
): Promise<T & { id: string }>;
/**
* Typed CRUD update — `SurrealAdapter.update`. Returns the updated
* record, or `null` when no row matched.
*/
update<T extends Record<string, unknown>>(
table: string,
id: string,
data: Partial<T>
): Promise<T | null>;
}

View File

@@ -0,0 +1,69 @@
# `service/domain-resolver.ts`
## Purpose
`DomainResolverService` maps a request's `Host` (or arbitrary domain string) to a persisted `site` record and the owning organization. It is the canonical lookup path used by the public resolution route, the Hono `domainResolverMiddleware`, and the optional auto-bootstrap flow. The service wraps the SurrealDB `fn::domain::search` function — which performs the actual pattern match — and shoulders the responsibility of normalising input (`normalizeDomain`), observing latency (`site.domain_resolution_duration_ms`), and partitioning the outcome counter into `hit` / `miss` / `error`.
The service deliberately returns a result envelope (`{success, site?, organization?, error?, code?}`) so happy-path consumers can branch without try/catch; the throwing variant (`resolveOrThrow`) exists for routes / middlewares that want typed exceptions.
## Features
- Async `resolve(domain)` returning a `DomainResolutionResult` envelope.
- `isDomainTaken(domain)` boolean probe.
- `resolveOrThrow(domain)` throws `SiteError(SITE.DOMAIN_NOT_FOUND)` on miss or `SITE.RESOLUTION_FAILED` on driver error.
- Pure domain normalisation via `normalizeDomain`.
- OTel counter `site.domain_resolutions_total` with `outcome` label.
- OTel histogram `site.domain_resolution_duration_ms`.
- Optional `LoggerLike` and `now()` injectables for deterministic tests.
## Usage: Basic
```ts
import { DomainResolverService } from "@suaveplan/api-plugin-site/service/domain-resolver";
const resolver = new DomainResolverService(db);
const result = await resolver.resolve("https://www.acme.example.com/");
if (result.success) {
console.log(result.site?.name, result.organization?.id);
}
```
## Usage: Advanced
```ts
import { DomainResolverService } from "@suaveplan/api-plugin-site/service/domain-resolver";
// Test-friendly: deterministic clock + injected logger.
const logger = { debug: () => {}, info: () => {}, warn: (m, ctx) => spy.push({ m, ctx }), error: () => {} };
let tick = 0;
const resolver = new DomainResolverService(db, {
logger,
now: () => (tick += 5),
});
try {
const { site } = await resolver.resolveOrThrow("acme.test");
return site;
} catch (err) {
if (err instanceof SiteError && err.code === SITE_CODES.DOMAIN_NOT_FOUND) {
return null;
}
throw err;
}
```
## API Reference
- `class DomainResolverService`
- `constructor(db: SiteDb, options?: { logger?; now?; })`
- `resolve(domain: string): Promise<DomainResolutionResult>`
- `isDomainTaken(domain: string): Promise<boolean>`
- `resolveOrThrow(domain: string): Promise<{ site: Site; organization: ResolvedOrganization }>`
- `interface DomainResolverServiceOptions` — `logger?: LoggerLike`, `now?: () => number`.
## Implementation Notes
- The single SurQL statement uses `fn::domain::search` so wildcard expansion / multi-domain matching is handled by the migration's function — application code stays simple.
- The result envelope's `code` field carries the canonical `SITE.*` code so callers can map to HTTP without re-deriving the failure mode from the error string.
- The duration histogram is recorded on every outcome (hit, miss, error) so dashboards can compare hit vs miss latency to detect cache-miss thrashing.
- `defaultNow` returns `Date.now()`; tests inject `() => ++t` so they assert non-zero duration without sleeping.

View File

@@ -0,0 +1,225 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SITE_CODES, SiteError } from "../error-codes/error-codes.js";
import type { SiteDb } from "./db-port.js";
import { DomainResolverService } from "./domain-resolver.js";
function makeDb(queryResult: unknown[] = [], throwValue?: unknown): SiteDb {
return {
query: async () => {
if (throwValue !== undefined) throw throwValue;
return queryResult as never;
},
create: async (_t, d) => ({ ...d, id: "noop" }) as never,
update: async () => null,
};
}
describe("DomainResolverService.resolve", () => {
it("returns DOMAIN_NOT_FOUND code on empty result", {
timeout: 5000,
}, async () => {
const svc = new DomainResolverService(makeDb([]));
const r = await svc.resolve("notfound.com");
expect(r.success).toBe(false);
expect(r.error).toBe("Domain not found");
expect(r.code).toBe(SITE_CODES.DOMAIN_NOT_FOUND);
});
it("returns RESOLUTION_FAILED code when db throws Error", {
timeout: 5000,
}, async () => {
const svc = new DomainResolverService(
makeDb([], new Error("connection refused"))
);
const r = await svc.resolve("example.com");
expect(r.success).toBe(false);
expect(r.error).toBe("connection refused");
expect(r.code).toBe(SITE_CODES.RESOLUTION_FAILED);
});
it("returns RESOLUTION_FAILED with fallback message on non-Error throwable", {
timeout: 5000,
}, async () => {
const failure: unknown = "bare string";
const svc = new DomainResolverService(makeDb([], failure));
const r = await svc.resolve("example.com");
expect(r.success).toBe(false);
expect(r.error).toBe("Resolution failed");
expect(r.code).toBe(SITE_CODES.RESOLUTION_FAILED);
});
it("returns success envelope when query yields a row", {
timeout: 5000,
}, async () => {
const row = {
site: {
id: "site:1",
organization_id: "organization:1",
name: "Acme",
domains: ["example.com"],
settings: {
maintenance_mode: false,
theme: {
primary_color: "#000033",
secondary_color: "#000066",
font_family: "",
},
features: [],
},
state: 1,
created_at: "2026",
updated_at: "2026",
},
organization: { id: "organization:1", name: "ACME", slug: "acme" },
};
const svc = new DomainResolverService(makeDb([row]));
const r = await svc.resolve("HTTPS://WWW.Example.Com/");
expect(r.success).toBe(true);
expect(r.site?.id).toBe("site:1");
expect(r.organization?.slug).toBe("acme");
});
it("treats sparse rows array as a miss", { timeout: 5000 }, async () => {
// Force an array-with-undefined-head to test the guard path.
const sparse: unknown[] = [];
sparse.length = 1; // single undefined slot
const svc = new DomainResolverService(makeDb(sparse));
const r = await svc.resolve("example.com");
expect(r.success).toBe(false);
expect(r.code).toBe(SITE_CODES.DOMAIN_NOT_FOUND);
});
it("normalises input before query bind", { timeout: 5000 }, async () => {
let bound: string | undefined;
const spyDb: SiteDb = {
query: async (_sql, params) => {
bound = (params?.[0] as string) ?? "";
return [];
},
create: async (_t, d) => ({ ...d, id: "noop" }) as never,
update: async () => null,
};
const svc = new DomainResolverService(spyDb);
await svc.resolve("HTTPS://WWW.EXAMPLE.COM/");
expect(bound).toBe("example.com");
});
});
describe("DomainResolverService.isDomainTaken", () => {
it("returns false on miss", { timeout: 5000 }, async () => {
const svc = new DomainResolverService(makeDb([]));
expect(await svc.isDomainTaken("nope.com")).toBe(false);
});
it("returns true on hit", { timeout: 5000 }, async () => {
const row = {
site: {
id: "site:1",
organization_id: "organization:1",
name: "Acme",
domains: ["example.com"],
settings: {
maintenance_mode: false,
theme: {
primary_color: "#000033",
secondary_color: "#000066",
font_family: "",
},
features: [],
},
state: 1,
created_at: "2026",
updated_at: "2026",
},
organization: { id: "organization:1", name: "ACME", slug: "acme" },
};
const svc = new DomainResolverService(makeDb([row]));
expect(await svc.isDomainTaken("example.com")).toBe(true);
});
});
describe("DomainResolverService.resolveOrThrow", () => {
it("throws DOMAIN_NOT_FOUND on miss", { timeout: 5000 }, async () => {
const svc = new DomainResolverService(makeDb([]));
let thrown: unknown;
try {
await svc.resolveOrThrow("nope.com");
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SiteError);
expect((thrown as SiteError).code).toBe(SITE_CODES.DOMAIN_NOT_FOUND);
});
it("throws RESOLUTION_FAILED on driver error", {
timeout: 5000,
}, async () => {
const svc = new DomainResolverService(makeDb([], new Error("boom")));
let thrown: unknown;
try {
await svc.resolveOrThrow("acme.com");
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(SiteError);
expect((thrown as SiteError).code).toBe(SITE_CODES.RESOLUTION_FAILED);
});
it("returns site+organization on hit", {
timeout: 5000,
}, async () => {
const row = {
site: {
id: "site:1",
organization_id: "organization:1",
name: "Acme",
domains: ["example.com"],
settings: {
maintenance_mode: false,
theme: {
primary_color: "#000033",
secondary_color: "#000066",
font_family: "",
},
features: [],
},
state: 1,
created_at: "2026",
updated_at: "2026",
},
organization: { id: "organization:1", name: "ACME", slug: "acme" },
};
const svc = new DomainResolverService(makeDb([row]));
const out = await svc.resolveOrThrow("example.com");
expect(out.site.id).toBe("site:1");
expect(out.organization.id).toBe("organization:1");
});
});
describe("DomainResolverService logger + clock injection", () => {
it("uses injected now() for duration histogram and emits logger.warn on error", {
timeout: 5000,
}, async () => {
const warnings: Array<{ msg: string; ctx?: unknown }> = [];
const svc = new DomainResolverService(
makeDb([], new Error("driver-down")),
{
logger: {
debug: () => undefined,
info: () => undefined,
warn: (m, ctx) => {
warnings.push({ msg: String(m), ctx });
},
error: () => undefined,
},
now: (() => {
let t = 0;
return () => (t += 3);
})(),
}
);
await svc.resolve("example.com");
expect(warnings.length).toBe(1);
expect(warnings[0]?.msg).toBe("domain resolve failed");
});
});

View File

@@ -0,0 +1,146 @@
import type { LoggerLike } from "@suaveplan/types";
import { SITE_CODES, SiteError } from "../error-codes/error-codes.js";
import { getSiteInstruments } from "../otel/instruments.js";
import type {
DomainResolutionResult,
ResolvedOrganization,
Site,
} from "../types/types.js";
import type { SiteDb } from "./db-port.js";
import { normalizeDomain } from "./normalize-domain.js";
/** Optional construction options for {@link DomainResolverService}. */
export interface DomainResolverServiceOptions {
/** Plug-in logger; defaults to no-op. */
logger?: LoggerLike;
/** High-resolution clock for OTel duration histograms. */
now?: () => number;
}
interface ResolveRow {
site: Site;
organization: ResolvedOrganization;
}
/**
* Resolves a domain string to its owning site and organization. Wraps
* the SurrealDB `fn::domain::search` function and normalises the input
* via {@link normalizeDomain} before issuing the query.
*
* Emits `site.domain_resolutions_total` (label: hit / miss / error) and
* the `site.domain_resolution_duration_ms` histogram on every call.
*/
export class DomainResolverService {
private readonly db: SiteDb;
private readonly logger: LoggerLike;
private readonly now: () => number;
public constructor(db: SiteDb, options: DomainResolverServiceOptions = {}) {
this.db = db;
this.logger = options.logger ?? createNoopLogger();
this.now = options.now ?? defaultNow;
}
/**
* Resolve a domain. Returns `success: true` plus the site +
* organization on a hit; `success: false` with a descriptive error
* on miss / failure.
*/
public async resolve(domain: string): Promise<DomainResolutionResult> {
const inst = getSiteInstruments();
const started = this.now();
const normalized = normalizeDomain(domain);
try {
const rows = await this.db.query<ResolveRow>(
`SELECT
*,
(SELECT id, name, slug FROM $parent.organization_id)[0] AS organization
FROM site
WHERE fn::domain::search(domains, $p0)
LIMIT 1`,
[normalized]
);
if (rows.length === 0) {
inst.domainResolutionsTotal.add(1, { outcome: "miss" });
inst.domainResolutionDurationMs.record(this.now() - started);
return {
success: false,
error: "Domain not found",
code: SITE_CODES.DOMAIN_NOT_FOUND,
};
}
const head = rows[0];
if (head === undefined) {
inst.domainResolutionsTotal.add(1, { outcome: "miss" });
inst.domainResolutionDurationMs.record(this.now() - started);
return {
success: false,
error: "Domain not found",
code: SITE_CODES.DOMAIN_NOT_FOUND,
};
}
inst.domainResolutionsTotal.add(1, { outcome: "hit" });
inst.domainResolutionDurationMs.record(this.now() - started);
return {
success: true,
site: head.site,
organization: head.organization,
};
} catch (cause) {
inst.domainResolutionsTotal.add(1, { outcome: "error" });
inst.domainResolutionDurationMs.record(this.now() - started);
const message =
cause instanceof Error ? cause.message : "Resolution failed";
this.logger.warn?.("domain resolve failed", {
domain: normalized,
message,
});
return {
success: false,
error: message,
code: SITE_CODES.RESOLUTION_FAILED,
};
}
}
/**
* Probe whether a domain is already claimed. Convenience wrapper
* around {@link resolve} that does not surface the resolution
* payload.
*/
public async isDomainTaken(domain: string): Promise<boolean> {
const result = await this.resolve(domain);
return result.success;
}
/**
* Throwing variant — raises `SITE.DOMAIN_NOT_FOUND` on miss,
* `SITE.RESOLUTION_FAILED` on driver error. Used by route layers
* that prefer typed exceptions over result envelopes.
*/
public async resolveOrThrow(
domain: string
): Promise<{ site: Site; organization: ResolvedOrganization }> {
const result = await this.resolve(domain);
if (result.success && result.site && result.organization) {
return { site: result.site, organization: result.organization };
}
const code =
result.code === SITE_CODES.RESOLUTION_FAILED
? SITE_CODES.RESOLUTION_FAILED
: SITE_CODES.DOMAIN_NOT_FOUND;
throw new SiteError(code, result.error as string);
}
}
function defaultNow(): number {
return Date.now();
}
function createNoopLogger(): LoggerLike {
const noop = (): void => undefined;
return { debug: noop, info: noop, warn: noop, error: noop };
}

View File

@@ -0,0 +1,44 @@
# `service/normalize-domain.ts`
## Purpose
Pure helper that mirrors the SurrealDB `fn::site::domain::normalize` function in TypeScript so application-tier code (the resolver, the route layer, the site service's `addDomain` / `removeDomain` paths) and database-tier code (the surql function) agree on the canonical form of a domain. Without this parity, a site stored with `"acme.example.com"` would be unreachable when an integrator naively passed `"HTTPS://WWW.Acme.Example.Com/"` from a `Host` header.
## Features
- Lower-cases, trims whitespace, strips `http://`/`https://`, strips `www.`, strips a single trailing slash.
- Pure function, zero deps, zero side effects.
- Single source of truth for normalisation — services and middleware call this exclusively.
## Usage: Basic
```ts
import { normalizeDomain } from "@suaveplan/api-plugin-site/service/normalize-domain";
normalizeDomain("HTTPS://WWW.Acme.Example.Com/");
// → "acme.example.com"
```
## Usage: Advanced
```ts
import { normalizeDomain } from "@suaveplan/api-plugin-site/service/normalize-domain";
// Use inside a middleware to canonicalise the request domain before
// hitting the DomainResolverService.
const host = c.req.header("host") ?? c.req.header("x-forwarded-host") ?? "";
const domain = normalizeDomain(host.split(":")[0] ?? host);
const resolved = await resolver.resolve(domain);
```
## API Reference
- `normalizeDomain(domain: string): string`
## Implementation Notes
- The order matters: `http(s)://` MUST be stripped before `www.` so that `https://www.example.com` reduces correctly.
- The trailing-slash strip is a single character — paths beyond the host are preserved by the caller's split (the middleware uses `host.split(":")` and the route layer uses the URL parser).
- Internationalised domains (`xn--`) are passed through untouched; callers needing IDNA normalisation must run punycode normalisation separately.
- The function is intentionally tolerant of leading whitespace and mixed case; in practice the `Host` header is canonical at the wire layer, but reverse proxies and edge workers occasionally pass through a value pulled from a configuration file that has neither been trimmed nor downcased. Normalisation here means the rest of the plugin can assume a single form.
- Because the function is pure and synchronous, it is safe to call inside hot paths (e.g. inside the Hono middleware) without any concern over async ordering or external I/O.

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { normalizeDomain } from "./normalize-domain.js";
describe("normalizeDomain", () => {
it("lowercases", { timeout: 5000 }, () => {
expect(normalizeDomain("EXAMPLE.COM")).toBe("example.com");
});
it("trims whitespace", { timeout: 5000 }, () => {
expect(normalizeDomain(" example.com ")).toBe("example.com");
});
it("strips https://", { timeout: 5000 }, () => {
expect(normalizeDomain("https://example.com")).toBe("example.com");
});
it("strips http://", { timeout: 5000 }, () => {
expect(normalizeDomain("http://example.com")).toBe("example.com");
});
it("strips www.", { timeout: 5000 }, () => {
expect(normalizeDomain("www.example.com")).toBe("example.com");
});
it("strips composed prefixes", { timeout: 5000 }, () => {
expect(normalizeDomain("HTTPS://WWW.Acme.Example.Com/")).toBe(
"acme.example.com"
);
});
it("preserves subdomains other than www", { timeout: 5000 }, () => {
expect(normalizeDomain("api.example.com")).toBe("api.example.com");
});
it("passes plain hosts through unchanged", { timeout: 5000 }, () => {
expect(normalizeDomain("example.com")).toBe("example.com");
});
});

View File

@@ -0,0 +1,22 @@
/**
* Normalise a domain string for consistent storage and lookup.
*
* Applies, in order:
* 1. lower-case
* 2. trim whitespace
* 3. strip leading `http://` / `https://`
* 4. strip leading `www.`
* 5. strip a single trailing `/`
*
* Pure — identical semantics to the SurrealDB `fn::site::domain::normalize`
* function so the application-tier and database-tier agree.
*/
export function normalizeDomain(domain: string): string {
const clean = domain
.toLowerCase()
.trim()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/$/, "");
return clean;
}

View File

@@ -0,0 +1,78 @@
# `service/site.ts`
## Purpose
`SiteService` is the application-tier owner of every CRUD path against the SurrealDB `site` table. It mediates between Hono routes / plugin event handlers and the database, enforces the per-org / per-site caps declared in the plugin config, normalises every domain before persistence, validates incoming data through Zod schemas at the boundary, and emits the canonical `site:settings:updated` event whenever a settings merge succeeds. The service is constructed once per plugin instance and registered on `ServiceRegistry["siteService"]` so downstream plugins and route handlers can resolve it without import-time coupling.
Unlike the upstream implementation, this service emits `site:settings:updated` from inside `updateSettings` rather than via runtime monkey-patching, and it returns the persisted row in every mutator so callers never have to re-read after write.
## Features
- Zod-validated `create` / `update` paths — invalid input fails with `SITE.VALIDATION` and the `ZodError` as `cause`.
- Per-org site cap (`maxSitesPerOrg`) and per-site domain cap (`maxDomainsPerSite`) enforced before the SurrealDB call.
- Default site provisioning via `createDefaultSite` (used by the `organization:created` listener).
- `updateSettings` performs a deep merge against the existing settings record and emits `site:settings:updated`.
- `updateState` and `archive(siteId)` for state-machine transitions.
- `addDomain` / `removeDomain` with normalisation, duplicate-rejection, and a "cannot remove last domain" guard.
- `search` + `getActive` helpers gated by config flags.
- OTel counters: `sites_created_total{outcome}`, `sites_updated_total{kind}` emitted on every state-changing path.
## Usage: Basic
```ts
import { SiteService } from "@suaveplan/api-plugin-site/service/site";
const service = new SiteService(db, config);
const site = await service.create({
organization_id: "organization:acme",
name: "Acme Marketing",
domains: ["acme.example.com"],
});
```
## Usage: Advanced
```ts
import { SiteService } from "@suaveplan/api-plugin-site/service/site";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus();
const service = new SiteService(db, config, { eventBus: bus, logger });
bus.on("site:settings:updated", (e) => cdn.invalidate(e.siteId));
const updated = await service.updateSettings("site:1", {
features: ["beta-cms", "experimental-i18n"],
});
await service.addDomain(updated.id, "acme.fresh.example");
await service.archive(updated.id);
```
## API Reference
- `class SiteService`
- `constructor(db: SiteDb, config: SitePluginConfig, options?: SiteServiceOptions)`
- `create(data: CreateSite): Promise<Site>`
- `createDefaultSite(org: OrganizationCreatedEvent): Promise<Site>`
- `getById(id: string): Promise<Site | null>`
- `requireById(id: string): Promise<Site>` — throws `SITE.SITE_NOT_FOUND`
- `listByOrganization(orgId: string): Promise<Site[]>`
- `countByOrganization(orgId: string): Promise<number>`
- `update(id: string, data: UpdateSite): Promise<Site>`
- `updateSettings(id: string, settings: Partial<SiteSettings>): Promise<Site>`
- `updateState(id: string, state: SiteStateNumber): Promise<Site>`
- `addDomain(id: string, domain: string): Promise<Site>`
- `removeDomain(id: string, domain: string): Promise<Site>`
- `archive(id: string): Promise<Site>`
- `getActive(): Promise<Site[]>`
- `search(query: string): Promise<Site[]>`
- `interface SiteServiceOptions` — `logger?: LoggerLike`, `eventBus?: EventBusLike<SiteEventMap>`.
## Implementation Notes
- The service uses the structural `SiteDb` port (`db-port.ts`) so unit tests supply minimal fakes. Production wiring resolves `SurrealAdapter` from `ServiceRegistry["db"]` which satisfies the port structurally.
- Every SurQL statement uses positional bindings (`$p0`, `$p1`) because the `DatabaseAdapter.query` signature is `(sql, readonly unknown[])`.
- `mergeSettings` performs a one-level deep merge over `theme` and shallow-replaces `features` / `push` so partial updates do not leak unwanted defaults into persistence.
- `addDomain` fixes an upstream bug — the old code computed a normalised list but never persisted it. This version always rewrites `domains`.
- `search` short-circuits to `[]` when `enableSiteSearch` is `false`, so config flips do not leak the search code path.

View File

@@ -0,0 +1,670 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import { SITE_CODES, SiteError } from "../error-codes/error-codes.js";
import { mergeConfig } from "../schemas/options.js";
import type { SiteEventMap, SiteSettingsUpdatedEvent } from "../types/types.js";
import type { SiteDb } from "./db-port.js";
import { NOOP_SITE_EVENT_BUS, SiteService } from "./site.js";
interface StoredSite {
id: string;
organization_id: string;
name: string;
domains: string[];
settings: Record<string, unknown>;
state: number;
created_at: string;
updated_at: string;
}
interface MemoryDbState {
sites: Map<string, StoredSite>;
counter: number;
}
function memoryDb(): { db: SiteDb; state: MemoryDbState } {
const state: MemoryDbState = { sites: new Map(), counter: 0 };
const db: SiteDb = {
query: async <T = Record<string, unknown>>(
sql: string,
params?: readonly unknown[]
): Promise<T[]> => {
const text = sql.replace(/\s+/g, " ").trim();
if (text.startsWith("SELECT * FROM site WHERE id =")) {
const id = params?.[0] as string;
const row = state.sites.get(id);
return (row ? [row] : []) as unknown as T[];
}
if (
text.startsWith(
"SELECT * FROM site WHERE organization_id = $p0 ORDER BY"
)
) {
const orgId = params?.[0] as string;
const rows = [...state.sites.values()].filter(
(s) => s.organization_id === orgId
);
return rows as unknown as T[];
}
if (
text.startsWith(
"SELECT count() as count FROM site WHERE organization_id = $p0 GROUP ALL"
)
) {
const orgId = params?.[0] as string;
const count = [...state.sites.values()].filter(
(s) => s.organization_id === orgId
).length;
return (count === 0 ? [] : [{ count }]) as unknown as T[];
}
if (
text.startsWith("SELECT * FROM site WHERE state = $p0 ORDER BY")
) {
const st = params?.[0] as number;
return [...state.sites.values()].filter(
(s) => s.state === st
) as unknown as T[];
}
if (
text.startsWith(
"SELECT * FROM site WHERE string::lowercase(name) CONTAINS"
)
) {
const q = (params?.[0] as string) ?? "";
return [...state.sites.values()].filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.domains.includes(q)
) as unknown as T[];
}
return [] as unknown as T[];
},
create: async <T extends Record<string, unknown>>(
_table: string,
data: T
): Promise<T & { id: string }> => {
state.counter += 1;
const id = `site:${state.counter}`;
const row = {
...(data as unknown as StoredSite),
id,
};
state.sites.set(id, row);
return { ...(data as object), id } as T & { id: string };
},
update: async <T extends Record<string, unknown>>(
_table: string,
id: string,
patch: Partial<T>
): Promise<T | null> => {
const row = state.sites.get(id);
if (!row) return null;
const merged = { ...row, ...(patch as Record<string, unknown>) };
state.sites.set(id, merged as StoredSite);
return merged as unknown as T;
},
};
return { db, state };
}
function makeBus(): {
bus: EventBusLike<SiteEventMap>;
captured: SiteSettingsUpdatedEvent[];
} {
const captured: SiteSettingsUpdatedEvent[] = [];
const listeners = new Map<keyof SiteEventMap, Array<(p: never) => void>>();
const bus: EventBusLike<SiteEventMap> = {
emit: (type, payload) => {
if (type === "site:settings:updated") {
captured.push(payload as SiteSettingsUpdatedEvent);
}
const arr = listeners.get(type) as
| Array<(p: unknown) => void>
| undefined;
arr?.forEach((fn) => {
fn(payload as unknown);
});
},
on: (type, fn) => {
const arr =
(listeners.get(type) as
| Array<(p: never) => void>
| undefined) ?? [];
arr.push(fn as (p: never) => void);
listeners.set(type, arr);
return () => {
const list = listeners.get(type);
if (!list) return;
listeners.set(
type,
list.filter((f) => f !== fn) as Array<(p: never) => void>
);
};
},
};
return { bus, captured };
}
describe("SiteService settings merge edge cases", () => {
it("preserves push config when merging non-overlapping patches", {
timeout: 5000,
}, async () => {
const { db, state } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
settings: {
push: {
apple: {
key_id: "K",
team_id: "T",
bundle_id: "B",
key_path: "/p",
environment: "sandbox",
},
},
},
});
// updateSettings with no push patch should preserve existing push.
await svc.updateSettings(site.id, { features: ["beta"] });
const persisted = state.sites.get(site.id) as
| { settings: { push?: { apple?: { key_id: string } } } }
| undefined;
expect(persisted?.settings.push?.apple?.key_id).toBe("K");
});
it("create with settings: undefined uses the no-patch clone branch", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
// Default branch: settings.theme primary_color reflects DEFAULT_SETTINGS.
expect(site.settings.theme.primary_color).toBe("#000033");
expect(site.settings.push).toBeUndefined();
});
});
describe("SiteService noop-bus default", () => {
it("does not throw when no eventBus is injected and updateSettings is called", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
const updated = await svc.updateSettings(site.id, {
features: ["beta"],
});
expect(updated.settings.features).toEqual(["beta"]);
});
it("NOOP_SITE_EVENT_BUS.on returns a no-op unsubscribe", {
timeout: 5000,
}, () => {
const off = NOOP_SITE_EVENT_BUS.on("site:initialized", () => undefined);
expect(typeof off).toBe("function");
// The unsubscribe is itself a no-op; calling it must not throw.
off();
});
});
describe("SiteService.create", () => {
it("creates a site with default settings + ACTIVE state", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "Acme",
domains: ["Acme.Example.Com"],
});
expect(site.id).toBe("site:1");
expect(site.state).toBe(1);
expect(site.domains).toEqual(["acme.example.com"]);
expect(site.settings.theme.primary_color).toBe("#000033");
});
it("throws SITE_LIMIT_EXCEEDED when over the cap", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig({ maxSitesPerOrg: 1 }));
await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
let caught: unknown;
try {
await svc.create({
organization_id: "organization:1",
name: "B",
domains: ["b.example.com"],
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.SITE_LIMIT_EXCEEDED);
});
it("throws DOMAIN_LIMIT_EXCEEDED when input exceeds per-site cap", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig({ maxDomainsPerSite: 1 }));
let caught: unknown;
try {
await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com", "b.example.com"],
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(
SITE_CODES.DOMAIN_LIMIT_EXCEEDED
);
});
it("throws VALIDATION on bad input", { timeout: 5000 }, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
let caught: unknown;
try {
await svc.create({
organization_id: "",
name: "",
domains: [],
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.VALIDATION);
});
it("createDefaultSite uses defaultDomainPattern and emits site name", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(
db,
mergeConfig({ defaultDomainPattern: "{slug}.acme.io" })
);
const site = await svc.createDefaultSite({
id: "organization:1",
name: "Best Co",
});
expect(site.domains).toContain("best-co.acme.io");
expect(site.name).toBe("Best Co Site");
});
});
describe("SiteService reads", () => {
it("getById returns null when missing", { timeout: 5000 }, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
expect(await svc.getById("site:nope")).toBeNull();
});
it("requireById throws SITE_NOT_FOUND on miss", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
let caught: unknown;
try {
await svc.requireById("site:nope");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.SITE_NOT_FOUND);
});
it("listByOrganization filters and returns rows", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
await svc.create({
organization_id: "organization:2",
name: "B",
domains: ["b.example.com"],
});
const rows = await svc.listByOrganization("organization:1");
expect(rows.length).toBe(1);
expect(rows[0]?.name).toBe("A");
});
it("countByOrganization counts zero when none exist", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
expect(await svc.countByOrganization("organization:1")).toBe(0);
});
});
describe("SiteService mutations + events", () => {
it("updateSettings merges, persists, and emits site:settings:updated", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const { bus, captured } = makeBus();
const svc = new SiteService(db, mergeConfig(), { eventBus: bus });
const created = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
const updated = await svc.updateSettings(created.id, {
features: ["beta"],
});
expect(updated.settings.features).toEqual(["beta"]);
expect(captured.length).toBe(1);
expect(captured[0]?.siteId).toBe(created.id);
expect(captured[0]?.changedFields).toEqual(["features"]);
});
it("update throws VALIDATION on unknown keys", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
let caught: unknown;
try {
await svc.update(site.id, {
sneaky: 1,
} as unknown as never);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.VALIDATION);
});
it("update throws SITE_NOT_FOUND when db returns null", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
let caught: unknown;
try {
await svc.update("site:absent", { name: "x" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.SITE_NOT_FOUND);
});
it("updateSettings re-throws SITE_NOT_FOUND if row vanishes mid-flight", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const created = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
// Patch the underlying update to return null while requireById still hits.
const original = db.update.bind(db);
db.update = async () => null;
let caught: unknown;
try {
await svc.updateSettings(created.id, { features: ["x"] });
} catch (e) {
caught = e;
}
db.update = original;
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.SITE_NOT_FOUND);
});
it("updateState transitions to ARCHIVED via archive()", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
const archived = await svc.archive(site.id);
expect(archived.state).toBe(8);
});
it("updateState throws SITE_NOT_FOUND on missing id", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
let caught: unknown;
try {
await svc.updateState("site:nope", 4);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.SITE_NOT_FOUND);
});
});
describe("SiteService domain management", () => {
it("addDomain persists the normalized domain", {
timeout: 5000,
}, async () => {
const { db, state } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
const updated = await svc.addDomain(site.id, "B.Example.Com");
expect(updated.domains).toEqual(["a.example.com", "b.example.com"]);
expect(state.sites.get(site.id)?.domains).toContain("b.example.com");
});
it("addDomain throws DOMAIN_ALREADY_CLAIMED on duplicates", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
let caught: unknown;
try {
await svc.addDomain(site.id, "A.Example.Com");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(
SITE_CODES.DOMAIN_ALREADY_CLAIMED
);
});
it("addDomain throws DOMAIN_LIMIT_EXCEEDED at the cap", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig({ maxDomainsPerSite: 1 }));
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
let caught: unknown;
try {
await svc.addDomain(site.id, "b.example.com");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(
SITE_CODES.DOMAIN_LIMIT_EXCEEDED
);
});
it("removeDomain refuses LAST_DOMAIN_REMOVAL", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
let caught: unknown;
try {
await svc.removeDomain(site.id, "a.example.com");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.LAST_DOMAIN_REMOVAL);
});
it("removeDomain succeeds when more than one domain remains", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com", "b.example.com"],
});
const updated = await svc.removeDomain(site.id, "b.example.com");
expect(updated.domains).toEqual(["a.example.com"]);
});
it("addDomain re-throws SITE_NOT_FOUND when update returns null", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com", "b.example.com"],
});
const original = db.update.bind(db);
db.update = async () => null;
let caught: unknown;
try {
await svc.addDomain(site.id, "c.example.com");
} catch (e) {
caught = e;
}
db.update = original;
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.SITE_NOT_FOUND);
});
it("removeDomain re-throws SITE_NOT_FOUND when update returns null", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const site = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com", "b.example.com"],
});
const original = db.update.bind(db);
db.update = async () => null;
let caught: unknown;
try {
await svc.removeDomain(site.id, "b.example.com");
} catch (e) {
caught = e;
}
db.update = original;
expect(caught).toBeInstanceOf(SiteError);
expect((caught as SiteError).code).toBe(SITE_CODES.SITE_NOT_FOUND);
});
});
describe("SiteService read helpers", () => {
it("getActive returns only state=1 rows", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
const a = await svc.create({
organization_id: "organization:1",
name: "A",
domains: ["a.example.com"],
});
await svc.create({
organization_id: "organization:1",
name: "B",
domains: ["b.example.com"],
});
await svc.archive(a.id);
const active = await svc.getActive();
expect(active.length).toBe(1);
expect(active[0]?.name).toBe("B");
});
it("search matches name substring", { timeout: 5000 }, async () => {
const { db } = memoryDb();
const svc = new SiteService(db, mergeConfig());
await svc.create({
organization_id: "organization:1",
name: "Acme Marketing",
domains: ["a.example.com"],
});
const found = await svc.search("acme");
expect(found.length).toBe(1);
});
it("search returns [] when enableSiteSearch is false", {
timeout: 5000,
}, async () => {
const { db } = memoryDb();
const svc = new SiteService(
db,
mergeConfig({ enableSiteSearch: false })
);
await svc.create({
organization_id: "organization:1",
name: "Acme",
domains: ["a.example.com"],
});
expect(await svc.search("acme")).toEqual([]);
});
});

View File

@@ -0,0 +1,436 @@
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import { SITE_CODES, SiteError } from "../error-codes/error-codes.js";
import { getSiteInstruments } from "../otel/instruments.js";
import type { SitePluginConfig } from "../schemas/options.js";
import type { CreateSite, UpdateSite } from "../schemas/site.js";
import { CreateSiteSchema, UpdateSiteSchema } from "../schemas/site.js";
import type {
OrganizationCreatedEvent,
Site,
SiteEventMap,
SiteSettings,
SiteSettingsUpdatedEvent,
SiteStateNumber,
} from "../types/types.js";
import { SITE_STATE_ACTIVE, SITE_STATE_ARCHIVED } from "../types/types.js";
import type { SiteDb } from "./db-port.js";
import { normalizeDomain } from "./normalize-domain.js";
/** Options accepted by the {@link SiteService} constructor. */
export interface SiteServiceOptions {
/** Plug-in logger. Defaults to a no-op. */
logger?: LoggerLike;
/**
* Event bus used to emit `site:settings:updated` and
* `site:bootstrap:failed`. Defaults to a no-op bus.
*/
eventBus?: EventBusLike<SiteEventMap>;
}
const SITE_TABLE = "site";
/**
* Default settings applied when a caller does not supply them. Matches
* the SurrealDB migration's defaults so freshly created records have
* the same shape from both writers.
*/
const DEFAULT_SETTINGS: SiteSettings = {
maintenance_mode: false,
theme: {
primary_color: "#000033",
secondary_color: "#000066",
font_family: "",
},
features: [],
};
/**
* Owns every CRUD path for the `site` table plus its embedded
* settings, state, and domain list. Internally emits
* `site:settings:updated` whenever `updateSettings` succeeds.
*/
export class SiteService {
private readonly db: SiteDb;
private readonly config: SitePluginConfig;
private readonly logger: LoggerLike;
private readonly eventBus: EventBusLike<SiteEventMap>;
public constructor(
db: SiteDb,
config: SitePluginConfig,
options: SiteServiceOptions = {}
) {
this.db = db;
this.config = config;
this.logger = options.logger ?? createNoopLogger();
this.eventBus = options.eventBus ?? createNoopBus();
}
/** Create a new site under an organization. */
public async create(data: CreateSite): Promise<Site> {
const parsed = CreateSiteSchema.safeParse(data);
if (!parsed.success) {
this.recordCreate("error");
throw new SiteError(
SITE_CODES.VALIDATION,
"createSite received invalid input",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const input = parsed.data;
const existingCount = await this.countByOrganization(
input.organization_id
);
if (existingCount >= this.config.maxSitesPerOrg) {
this.recordCreate("error");
throw new SiteError(
SITE_CODES.SITE_LIMIT_EXCEEDED,
`Maximum ${this.config.maxSitesPerOrg} sites per organization`,
{
context: {
statusCode: 400,
organization_id: input.organization_id,
},
}
);
}
if (input.domains.length > this.config.maxDomainsPerSite) {
this.recordCreate("error");
throw new SiteError(
SITE_CODES.DOMAIN_LIMIT_EXCEEDED,
`Maximum ${this.config.maxDomainsPerSite} domains per site`,
{ context: { statusCode: 400 } }
);
}
const normalizedDomains = input.domains.map(normalizeDomain);
const mergedSettings: SiteSettings = mergeSettings(
DEFAULT_SETTINGS,
input.settings as Partial<SiteSettings> | undefined
);
const now = new Date().toISOString();
const record = await this.db.create<Record<string, unknown>>(
SITE_TABLE,
{
organization_id: input.organization_id,
name: input.name,
domains: normalizedDomains,
settings: mergedSettings,
state: SITE_STATE_ACTIVE,
created_at: now,
updated_at: now,
}
);
this.recordCreate("success");
this.logger.debug?.("site created", {
id: record.id,
organization_id: input.organization_id,
});
return record as unknown as Site;
}
/**
* Create the conventional default site for a freshly-emitted
* `organization:created` event. Returns the persisted site so the
* caller can react (e.g. seed a sitemap).
*/
public async createDefaultSite(
organization: OrganizationCreatedEvent
): Promise<Site> {
const slug =
organization.slug ??
organization.name.toLowerCase().replace(/\s+/g, "-");
const domain = this.config.defaultDomainPattern.replace("{slug}", slug);
return this.create({
organization_id: organization.id,
name: `${organization.name} Site`,
domains: [domain],
});
}
/** Look up a site by id; returns `null` on miss. */
public async getById(siteId: string): Promise<Site | null> {
const rows = await this.db.query<Site>(
"SELECT * FROM site WHERE id = $p0 LIMIT 1",
[siteId]
);
return rows.length === 0 ? null : (rows[0] as Site);
}
/** Throwing read — raises `SITE.SITE_NOT_FOUND` on miss. */
public async requireById(siteId: string): Promise<Site> {
const site = await this.getById(siteId);
if (!site) {
throw new SiteError(
SITE_CODES.SITE_NOT_FOUND,
`Site '${siteId}' not found`,
{ context: { statusCode: 404, id: siteId } }
);
}
return site;
}
/** List every site for an organization, newest first. */
public async listByOrganization(organizationId: string): Promise<Site[]> {
const rows = await this.db.query<Site>(
"SELECT * FROM site WHERE organization_id = $p0 ORDER BY created_at DESC",
[organizationId]
);
return rows;
}
/** Count the sites owned by an organization. */
public async countByOrganization(organizationId: string): Promise<number> {
const rows = await this.db.query<{ count: number }>(
"SELECT count() as count FROM site WHERE organization_id = $p0 GROUP ALL",
[organizationId]
);
const head = rows[0];
return head === undefined ? 0 : Number(head.count);
}
/** Update a site (merge semantics) and return the persisted row. */
public async update(siteId: string, data: UpdateSite): Promise<Site> {
const parsed = UpdateSiteSchema.safeParse(data);
if (!parsed.success) {
throw new SiteError(
SITE_CODES.VALIDATION,
"updateSite received invalid input",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const updated = await this.db.update<Record<string, unknown>>(
SITE_TABLE,
siteId,
{
...parsed.data,
updated_at: new Date().toISOString(),
}
);
if (!updated) {
throw new SiteError(
SITE_CODES.SITE_NOT_FOUND,
`Site '${siteId}' not found`,
{ context: { statusCode: 404 } }
);
}
this.recordUpdate("fields");
return updated as unknown as Site;
}
/**
* Merge new settings into the site's existing settings. Emits
* `site:settings:updated` on success. The previous service relied
* on monkey-patching to emit; we emit from inside the method so the
* behaviour is part of the public contract.
*/
public async updateSettings(
siteId: string,
settings: Partial<SiteSettings>
): Promise<Site> {
const site = await this.requireById(siteId);
const merged = mergeSettings(site.settings, settings);
const updated = await this.db.update<Record<string, unknown>>(
SITE_TABLE,
siteId,
{
settings: merged,
updated_at: new Date().toISOString(),
}
);
if (!updated) {
throw new SiteError(
SITE_CODES.SITE_NOT_FOUND,
`Site '${siteId}' disappeared during settings update`,
{ context: { statusCode: 404 } }
);
}
this.recordUpdate("settings");
const payload: SiteSettingsUpdatedEvent = {
siteId,
changedFields: Object.keys(settings),
timestamp: new Date(),
};
this.eventBus.emit("site:settings:updated", payload);
return updated as unknown as Site;
}
/** Replace the site's lifecycle state (`1 | 2 | 4 | 8`). */
public async updateState(
siteId: string,
state: SiteStateNumber
): Promise<Site> {
const updated = await this.db.update<Record<string, unknown>>(
SITE_TABLE,
siteId,
{
state,
updated_at: new Date().toISOString(),
}
);
if (!updated) {
throw new SiteError(
SITE_CODES.SITE_NOT_FOUND,
`Site '${siteId}' not found`,
{ context: { statusCode: 404 } }
);
}
this.recordUpdate("state");
return updated as unknown as Site;
}
/**
* Add a domain to a site. Enforces the per-site domain cap and the
* uniqueness invariant.
*/
public async addDomain(siteId: string, domain: string): Promise<Site> {
const site = await this.requireById(siteId);
if (site.domains.length >= this.config.maxDomainsPerSite) {
throw new SiteError(
SITE_CODES.DOMAIN_LIMIT_EXCEEDED,
`Maximum ${this.config.maxDomainsPerSite} domains per site`,
{ context: { statusCode: 400 } }
);
}
const normalized = normalizeDomain(domain);
if (site.domains.includes(normalized)) {
throw new SiteError(
SITE_CODES.DOMAIN_ALREADY_CLAIMED,
`Domain '${normalized}' already on this site`,
{ context: { statusCode: 409 } }
);
}
const domains = [...site.domains, normalized];
const updated = await this.db.update<Record<string, unknown>>(
SITE_TABLE,
siteId,
{
domains,
updated_at: new Date().toISOString(),
}
);
if (!updated) {
throw new SiteError(
SITE_CODES.SITE_NOT_FOUND,
`Site '${siteId}' disappeared during addDomain`,
{ context: { statusCode: 404 } }
);
}
this.recordUpdate("domain");
return updated as unknown as Site;
}
/** Remove a domain. Refuses to leave the site with zero domains. */
public async removeDomain(siteId: string, domain: string): Promise<Site> {
const site = await this.requireById(siteId);
if (site.domains.length <= 1) {
throw new SiteError(
SITE_CODES.LAST_DOMAIN_REMOVAL,
"Cannot remove the last domain from a site",
{ context: { statusCode: 400 } }
);
}
const normalized = normalizeDomain(domain);
const domains = site.domains.filter((d) => d !== normalized);
const updated = await this.db.update<Record<string, unknown>>(
SITE_TABLE,
siteId,
{
domains,
updated_at: new Date().toISOString(),
}
);
if (!updated) {
throw new SiteError(
SITE_CODES.SITE_NOT_FOUND,
`Site '${siteId}' disappeared during removeDomain`,
{ context: { statusCode: 404 } }
);
}
this.recordUpdate("domain");
return updated as unknown as Site;
}
/** Archive (soft-delete) a site by flipping `state` to ARCHIVED. */
public async archive(siteId: string): Promise<Site> {
return this.updateState(siteId, SITE_STATE_ARCHIVED);
}
/** Return every active site in the database, newest first. */
public async getActive(): Promise<Site[]> {
const rows = await this.db.query<Site>(
"SELECT * FROM site WHERE state = $p0 ORDER BY created_at DESC",
[SITE_STATE_ACTIVE]
);
return rows;
}
/**
* Search sites by name substring or domain. Returns up to 100
* matches.
*/
public async search(query: string): Promise<Site[]> {
if (!this.config.enableSiteSearch) {
return [];
}
const needle = query.toLowerCase();
const rows = await this.db.query<Site>(
`SELECT * FROM site
WHERE string::lowercase(name) CONTAINS $p0
OR $p0 IN domains
ORDER BY created_at DESC
LIMIT 100`,
[needle]
);
return rows;
}
private recordCreate(outcome: "success" | "error"): void {
getSiteInstruments().sitesCreatedTotal.add(1, { outcome });
}
private recordUpdate(
kind: "settings" | "fields" | "state" | "domain"
): void {
getSiteInstruments().sitesUpdatedTotal.add(1, { kind });
}
}
function mergeSettings(
base: SiteSettings,
patch: Partial<SiteSettings> | undefined
): SiteSettings {
const effective: Partial<SiteSettings> = patch ?? {};
const merged: SiteSettings = {
maintenance_mode: effective.maintenance_mode ?? base.maintenance_mode,
theme: { ...base.theme, ...(effective.theme ?? {}) },
features: effective.features ?? base.features,
};
const push = effective.push ?? base.push;
if (push !== undefined) merged.push = push;
return merged;
}
function createNoopLogger(): LoggerLike {
const noop = (): void => undefined;
return { debug: noop, info: noop, warn: noop, error: noop };
}
const NOOP_UNSUBSCRIBE = (): void => undefined;
/**
* Default no-op event bus used when callers don't supply one. Exported
* so callers reaching for a stub can reuse the single instance.
*/
export const NOOP_SITE_EVENT_BUS: EventBusLike<SiteEventMap> = {
emit: (): void => undefined,
on: (): (() => void) => NOOP_UNSUBSCRIBE,
};
function createNoopBus(): EventBusLike<SiteEventMap> {
return NOOP_SITE_EVENT_BUS;
}

View File

@@ -0,0 +1,58 @@
# `services/registry-augmentation.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Augments the `ServiceRegistry` interface from `@suaveplan/api-plugin` so the three slots the site plugin owns — `siteService`, `domainResolver`, `domainResolverMiddleware` — resolve with full typing for any downstream consumer that imports the package root. Module augmentation is the canonical suaveplan pattern for cross-plugin typed services: it keeps `ctx.getService("siteService")` strongly typed without forcing every consumer to import a plugin-specific type and without leaking implementation details through the augmentation.
The file also re-declares the host-side `app` and `db` slots (owned by `@suaveplan/api` and `@suaveplan/api-plugin-database` respectively) so the site plugin's `onInit` can resolve them with typing even when the consumer has not explicitly imported those upstream packages. TypeScript merges duplicate `declare module` blocks, so this is additive and safe.
## Features
- Declares `siteService: SiteService` on `ServiceRegistry`.
- Declares `domainResolver: DomainResolverService` on `ServiceRegistry`.
- Declares `domainResolverMiddleware: DomainResolverMiddleware` on `ServiceRegistry`.
- Re-declares `app: OpenAPIHono` and `db: SurrealAdapter` for cumulative resolution.
- Zero runtime cost — the entire module is a type-only `declare module` block.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-site"; // pulls in the augmentation transitively
// In a downstream plugin onInit:
const siteService = ctx.getService("siteService"); // typed: SiteService | undefined
```
## Usage: Advanced
```ts
import "@suaveplan/api-plugin-site/services/registry-augmentation";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
myAppCustomSlot: MyType;
}
}
const my = ctx.getService("myAppCustomSlot");
```
## API Reference
No runtime exports. The file adds five slots to `ServiceRegistry`:
- `siteService: SiteService`
- `domainResolver: DomainResolverService`
- `domainResolverMiddleware: DomainResolverMiddleware`
- `app: OpenAPIHono`
- `db: SurrealAdapter`
## Implementation Notes
- The augmentation is imported as a side-effect via the package barrel, so consumers automatically get the typed slots — no explicit `import "@suaveplan/api-plugin-site/services/registry-augmentation"` needed.
- Re-declaring host slots is documented as safe in the suaveplan convention (see `@suaveplan/api-plugin-auth`'s registry-augmentation for prior art).
- The `db` slot is typed as `SurrealAdapter` rather than `DatabaseAdapter` because the site service relies on typed CRUD (`create`, `update`) which only `SurrealAdapter` exposes. The service consumes a structural `SiteDb` port, but the `ServiceRegistry` slot is the live adapter type.

View File

@@ -0,0 +1,30 @@
/**
* ServiceRegistry module augmentation — registers the three slots the
* site plugin owns on `@suaveplan/api-plugin/service-registry`'s
* `ServiceRegistry` interface so downstream plugins get fully-typed
* `ctx.getService("siteService")` lookups.
*
* Importing this module for its side effects (which the package root
* barrel does automatically) is sufficient.
*
* Host-side slots (`app`, `db`) are owned by `@suaveplan/api` /
* `@suaveplan/api-plugin-database`'s own augmentations. They are
* re-declared here as a cumulative augmentation so the site plugin's
* `onInit(ctx)` can resolve them with full typing even when the
* consumer has not explicitly imported the upstream plugin.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { DomainResolverMiddleware } from "../middleware/domain-resolver.js";
import type { DomainResolverService } from "../service/domain-resolver.js";
import type { SiteService } from "../service/site.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
siteService: SiteService;
domainResolver: DomainResolverService;
domainResolverMiddleware: DomainResolverMiddleware;
app: OpenAPIHono;
db: SurrealAdapter;
}
}

View File

@@ -0,0 +1,51 @@
# `types/types.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Runtime-free type module that defines every shared shape used by the site plugin: persisted `Site` / `SiteSettings` / `SiteTheme`, push-config interfaces, request/response payloads, event-bus topic map (`SiteEventMap`), and the numeric state constants (`SITE_STATE_ACTIVE` etc.). The module exports both the constants (runtime values used by the SurrealDB-bound code) and the interfaces consumed by services, routes, plugin, and tests. Keeping every shared shape here ensures the inferred Zod types (in `schemas/`) and the canonical hand-rolled types stay in lock-step.
## Features
- Numeric state constants — `SITE_STATE_ACTIVE = 1`, `SITE_STATE_MAINTENANCE = 2`, `SITE_STATE_SUSPENDED = 4`, `SITE_STATE_ARCHIVED = 8` — plus the union `SiteStateNumber`.
- `Site`, `SiteSettings`, `SiteTheme`, `ApplePushConfig`, `FirebasePushConfig`, `PushConfig`, `CreateSite`, `UpdateSite`, `SitePublicInfo`.
- `DomainResolutionResult` envelope returned by `DomainResolverService.resolve`.
- `SiteSettingsUpdatedEvent`, `SiteBootstrapFailedEvent`, `OrganizationCreatedEvent` payloads.
- `SiteEventMap` keyed by event topic (extends `Record<string, unknown>` so it satisfies `EventBusLike`'s constraint).
- `SitePluginLogger` alias for `LoggerLike` used by the plugin.
## Usage: Basic
```ts
import { SITE_STATE_ACTIVE, type Site } from "@suaveplan/api-plugin-site/types/types";
const fresh: Pick<Site, "state"> = { state: SITE_STATE_ACTIVE };
```
## Usage: Advanced
```ts
import type { EventBusLike } from "@suaveplan/types";
import type { SiteEventMap, SiteSettingsUpdatedEvent } from "@suaveplan/api-plugin-site/types/types";
function attach(bus: EventBusLike<SiteEventMap>): () => void {
return bus.on("site:settings:updated", (e: SiteSettingsUpdatedEvent) => {
invalidateCdn(e.siteId);
});
}
```
## API Reference
- Constants: `SITE_STATE_ACTIVE`, `SITE_STATE_MAINTENANCE`, `SITE_STATE_SUSPENDED`, `SITE_STATE_ARCHIVED`.
- Types: `SiteStateNumber`, `Site`, `SiteSettings`, `SiteTheme`, `ApplePushConfig`, `FirebasePushConfig`, `PushConfig`, `CreateSite`, `UpdateSite`, `SitePublicInfo`, `ResolvedOrganization`, `DomainResolutionResult`, `SiteSettingsUpdatedEvent`, `SiteBootstrapFailedEvent`, `OrganizationCreatedEvent`, `SitePluginLogger`.
- Event map: `interface SiteEventMap extends Record<string, unknown>` with five topics.
## Implementation Notes
- The state constants live here (not in `schemas/site.ts`) so consumers can import them without pulling in Zod at runtime — relevant for client bundles that only need to render a state indicator.
- `SiteEventMap` extends `Record<string, unknown>` to satisfy `EventBusLike<TMap extends Record<string, unknown>>`. Without the extension TypeScript refuses the type parameter and the plugin loses its typed event emit / on.
- Type aliases here are hand-rolled. They are kept in lock-step with the Zod-inferred types in `schemas/site.ts` by test-time assignability checks in `service/site.test.ts` (creating a `Site` value satisfies both shapes).

View File

@@ -0,0 +1,141 @@
import type { LoggerLike } from "@suaveplan/types";
/**
* Site state enum — mirrors the SurrealDB bitwise constants.
*/
export const SITE_STATE_ACTIVE = 1;
export const SITE_STATE_MAINTENANCE = 2;
export const SITE_STATE_SUSPENDED = 4;
export const SITE_STATE_ARCHIVED = 8;
/** Union of legal site-state numeric values. */
export type SiteStateNumber =
| typeof SITE_STATE_ACTIVE
| typeof SITE_STATE_MAINTENANCE
| typeof SITE_STATE_SUSPENDED
| typeof SITE_STATE_ARCHIVED;
/** Apple Push Notification config. */
export interface ApplePushConfig {
key_id: string;
team_id: string;
bundle_id: string;
key_path: string;
environment: "sandbox" | "production";
}
/** Firebase Cloud Messaging push config. */
export interface FirebasePushConfig {
server_key: string;
project_id: string;
}
/** Push-notification configuration bundle. */
export interface PushConfig {
apple?: ApplePushConfig;
firebase?: FirebasePushConfig;
}
/** Site theme palette. */
export interface SiteTheme {
primary_color: string;
secondary_color: string;
font_family: string;
}
/** Site settings persisted with the site record. */
export interface SiteSettings {
maintenance_mode: boolean;
theme: SiteTheme;
features: string[];
push?: PushConfig;
}
/** Persisted site record. */
export interface Site {
id: string;
organization_id: string;
name: string;
slug?: string;
domains: string[];
settings: SiteSettings;
state: SiteStateNumber;
created_at: string;
updated_at: string;
}
/** Input to `SiteService.create`. */
export interface CreateSite {
organization_id: string;
name: string;
domains: string[];
settings?: Partial<SiteSettings>;
}
/** Input to `SiteService.update`. */
export interface UpdateSite {
name?: string;
settings?: Partial<SiteSettings>;
}
/** Public-facing slice of a site record (no secrets). */
export interface SitePublicInfo {
id: string;
name: string;
slug?: string;
domains: string[];
settings: {
theme: SiteTheme;
features: string[];
};
state: SiteStateNumber;
}
/** Organization stub returned alongside a domain resolution. */
export interface ResolvedOrganization {
id: string;
name: string;
slug: string;
}
/** Outcome of `DomainResolverService.resolve`. */
export interface DomainResolutionResult {
success: boolean;
site?: Site;
organization?: ResolvedOrganization;
error?: string;
code?: string;
}
/** Payload of the `site:settings:updated` event. */
export interface SiteSettingsUpdatedEvent {
siteId: string;
changedFields: string[];
timestamp: Date;
}
/** Payload of the `site:bootstrap:failed` event. */
export interface SiteBootstrapFailedEvent {
organizationId: string;
message: string;
timestamp: Date;
}
/** Payload of `organization:created` — what the site plugin subscribes to. */
export interface OrganizationCreatedEvent {
id: string;
name: string;
slug?: string;
}
/** Map of every site-plugin event, keyed by event topic. */
export interface SiteEventMap extends Record<string, unknown> {
"site:initialized": undefined;
"site:ready": undefined;
"site:settings:updated": SiteSettingsUpdatedEvent;
"site:bootstrap:failed": SiteBootstrapFailedEvent;
"organization:created": OrganizationCreatedEvent;
}
/** Optional plugin-scoped logger. Defaults to a no-op LoggerLike. */
export type SitePluginLogger = LoggerLike;

View File

@@ -0,0 +1,67 @@
-- Define constants for site states (bitwise flags)
DEFINE PARAM OVERWRITE $SITE_STATE_ACTIVE VALUE 1; -- 0001
DEFINE PARAM OVERWRITE $SITE_STATE_MAINTENANCE VALUE 2; -- 0010
DEFINE PARAM OVERWRITE $SITE_STATE_SUSPENDED VALUE 4; -- 0100
DEFINE PARAM OVERWRITE $SITE_STATE_ARCHIVED VALUE 8; -- 1000
DEFINE TABLE OVERWRITE site SCHEMAFULL;
DEFINE FIELD OVERWRITE organization_id ON site TYPE record<organization>
ASSERT $value != NONE;
DEFINE FIELD OVERWRITE name ON site TYPE string
ASSERT $value != NONE
AND string::len($value) > 0
AND string::len($value) <= 100;
DEFINE FIELD OVERWRITE slug ON site TYPE option<string>;
DEFINE FIELD OVERWRITE domains ON site TYPE array<string>
ASSERT $value != NONE;
DEFINE FIELD OVERWRITE settings ON site TYPE object;
DEFINE FIELD OVERWRITE settings.maintenance_mode ON site TYPE bool DEFAULT false;
DEFINE FIELD OVERWRITE settings.theme ON site TYPE object;
DEFINE FIELD OVERWRITE settings.theme.primary_color ON site TYPE string DEFAULT '#000033';
DEFINE FIELD OVERWRITE settings.theme.secondary_color ON site TYPE string DEFAULT '#000066';
DEFINE FIELD OVERWRITE settings.theme.font_family ON site TYPE string DEFAULT '';
DEFINE FIELD OVERWRITE settings.features ON site TYPE array<string> DEFAULT [];
-- Push notification settings
DEFINE FIELD OVERWRITE settings.push ON site TYPE option<object>;
DEFINE FIELD OVERWRITE settings.push.apple ON site TYPE option<object>;
DEFINE FIELD OVERWRITE settings.push.apple.key_id ON site TYPE string;
DEFINE FIELD OVERWRITE settings.push.apple.team_id ON site TYPE string;
DEFINE FIELD OVERWRITE settings.push.apple.bundle_id ON site TYPE string;
DEFINE FIELD OVERWRITE settings.push.apple.key_path ON site TYPE string;
DEFINE FIELD OVERWRITE settings.push.apple.environment ON site TYPE string DEFAULT 'sandbox';
DEFINE FIELD OVERWRITE settings.push.firebase ON site TYPE option<object>;
DEFINE FIELD OVERWRITE settings.push.firebase.server_key ON site TYPE string;
DEFINE FIELD OVERWRITE settings.push.firebase.project_id ON site TYPE string;
DEFINE FIELD OVERWRITE state ON site TYPE number
ASSERT $value INSIDE [1, 2, 4, 8]
DEFAULT 1;
DEFINE FIELD OVERWRITE created_at ON site TYPE string
DEFAULT time::format(time::now(), "%+");
DEFINE FIELD OVERWRITE updated_at ON site TYPE string
DEFAULT time::format(time::now(), "%+")
VALUE time::format(time::now(), "%+");
DEFINE INDEX OVERWRITE site_organization ON site FIELDS organization_id;
DEFINE INDEX OVERWRITE site_name_org ON site FIELDS organization_id, name UNIQUE;
DEFINE INDEX OVERWRITE site_domains ON site FIELDS domains UNIQUE;
DEFINE INDEX OVERWRITE site_state ON site FIELDS state;
-- Domain pattern search across the per-row domain array.
DEFINE FUNCTION OVERWRITE fn::domain::search($domains: array<string>, $domainSearch: string) -> bool {
FOR $domain IN $domains {
IF string::matches($domainSearch, $domain) {
return true;
};
};
return false;
};

View 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"]
}

View File

@@ -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", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-site",
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\//,
/^@opentelemetry\//,
/^@hono\//,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -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/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});