Initial Commit
This commit is contained in:
101
packages/api-stack/api/api-plugin-site/README.md
Normal file
101
packages/api-stack/api/api-plugin-site/README.md
Normal 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.
|
||||
|
||||
@@ -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"`.
|
||||
152
packages/api-stack/api/api-plugin-site/package.json
Normal file
152
packages/api-stack/api/api-plugin-site/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
130
packages/api-stack/api/api-plugin-site/src/index.ts
Normal file
130
packages/api-stack/api/api-plugin-site/src/index.ts
Normal 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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
]);
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
66
packages/api-stack/api/api-plugin-site/src/plugin/plugin.md
Normal file
66
packages/api-stack/api/api-plugin-site/src/plugin/plugin.md
Normal 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.
|
||||
403
packages/api-stack/api/api-plugin-site/src/plugin/plugin.test.ts
Normal file
403
packages/api-stack/api/api-plugin-site/src/plugin/plugin.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
225
packages/api-stack/api/api-plugin-site/src/plugin/plugin.ts
Normal file
225
packages/api-stack/api/api-plugin-site/src/plugin/plugin.ts
Normal 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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
48
packages/api-stack/api/api-plugin-site/src/schemas/domain.md
Normal file
48
packages/api-stack/api/api-plugin-site/src/schemas/domain.md
Normal 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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
68
packages/api-stack/api/api-plugin-site/src/schemas/domain.ts
Normal file
68
packages/api-stack/api/api-plugin-site/src/schemas/domain.ts
Normal 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>;
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
56
packages/api-stack/api/api-plugin-site/src/schemas/push.md
Normal file
56
packages/api-stack/api/api-plugin-site/src/schemas/push.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
86
packages/api-stack/api/api-plugin-site/src/schemas/push.ts
Normal file
86
packages/api-stack/api/api-plugin-site/src/schemas/push.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
69
packages/api-stack/api/api-plugin-site/src/schemas/site.md
Normal file
69
packages/api-stack/api/api-plugin-site/src/schemas/site.md
Normal 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.
|
||||
100
packages/api-stack/api/api-plugin-site/src/schemas/site.test.ts
Normal file
100
packages/api-stack/api/api-plugin-site/src/schemas/site.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
111
packages/api-stack/api/api-plugin-site/src/schemas/site.ts
Normal file
111
packages/api-stack/api/api-plugin-site/src/schemas/site.ts
Normal 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>;
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
78
packages/api-stack/api/api-plugin-site/src/service/site.md
Normal file
78
packages/api-stack/api/api-plugin-site/src/service/site.md
Normal 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.
|
||||
670
packages/api-stack/api/api-plugin-site/src/service/site.test.ts
Normal file
670
packages/api-stack/api/api-plugin-site/src/service/site.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
436
packages/api-stack/api/api-plugin-site/src/service/site.ts
Normal file
436
packages/api-stack/api/api-plugin-site/src/service/site.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
51
packages/api-stack/api/api-plugin-site/src/types/types.md
Normal file
51
packages/api-stack/api/api-plugin-site/src/types/types.md
Normal 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).
|
||||
141
packages/api-stack/api/api-plugin-site/src/types/types.ts
Normal file
141
packages/api-stack/api/api-plugin-site/src/types/types.ts
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
12
packages/api-stack/api/api-plugin-site/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-site/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-site/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-site/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
37
packages/api-stack/api/api-plugin-site/vite.config.ts
Normal file
37
packages/api-stack/api/api-plugin-site/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-plugin-site/vitest.config.ts
Normal file
31
packages/api-stack/api/api-plugin-site/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user