first commit
This commit is contained in:
101
docs/api-stack/api/api-plugin-site/README.md
Normal file
101
docs/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"`.
|
||||
@@ -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,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,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.
|
||||
63
docs/api-stack/api/api-plugin-site/src/otel/instruments.md
Normal file
63
docs/api-stack/api/api-plugin-site/src/otel/instruments.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# `otel/instruments.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the lazy-built OpenTelemetry instrument bundle (`SiteInstruments`) that every observable code path in `@suaveplan/api-plugin-site` emits to. Instruments are constructed against the global `MeterProvider` on first call and cached so each call to `getSiteInstruments()` reuses the same `Counter`/`Histogram` handles. `resetSiteInstruments()` drops the cache so tests can install a fresh `MeterProvider` and re-bind against it — the canonical suaveplan pattern used by every other plugin's OTel module.
|
||||
|
||||
## Features
|
||||
|
||||
- `sitesCreatedTotal{outcome}` counter — `success` | `error`.
|
||||
- `sitesUpdatedTotal{kind}` counter — `settings` | `fields` | `state` | `domain`.
|
||||
- `domainResolutionsTotal{outcome}` counter — `hit` | `miss` | `error`.
|
||||
- `domainResolutionDurationMs` histogram — wall-clock ms.
|
||||
- `bootstrapFailedTotal` counter — incremented when `createDefaultSite` fails during an `organization:created` event.
|
||||
- Cache reset for test rebinding via `resetSiteInstruments()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSiteInstruments } from "@suaveplan/api-plugin-site/otel/instruments";
|
||||
|
||||
const inst = getSiteInstruments();
|
||||
inst.sitesCreatedTotal.add(1, { outcome: "success" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
getSiteInstruments,
|
||||
resetSiteInstruments,
|
||||
} from "@suaveplan/api-plugin-site/otel/instruments";
|
||||
|
||||
beforeAll(() => {
|
||||
const mp = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(mp);
|
||||
resetSiteInstruments();
|
||||
});
|
||||
|
||||
it("counts a resolution miss", () => {
|
||||
const inst = getSiteInstruments();
|
||||
inst.domainResolutionsTotal.add(1, { outcome: "miss" });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SiteInstruments` — five readonly handles.
|
||||
- `getSiteInstruments(): SiteInstruments` — lazy + cached.
|
||||
- `resetSiteInstruments(): void` — drop cache so the next call rebinds.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cache lives at module scope. Reset is required between tests that swap the global provider — without it, an old handle pinned to a destroyed provider would record forever into the void.
|
||||
- Metric names follow `<package>.<noun>_total` / `_duration_ms` convention so dashboards can grep across plugins consistently.
|
||||
- The histogram unit is `ms` so collectors can render p50/p95/p99 latency without additional conversion.
|
||||
- The counter / histogram handles returned by `getSiteInstruments()` are safe to retain across requests — they are thin proxies over the underlying `MeterProvider`'s metric registry, and recreating them on every call would defeat the purpose of OTel's batched export pipeline. Tests that swap providers therefore MUST call `resetSiteInstruments()` before the next read so the next handle points at the new provider.
|
||||
- This module avoids any side effects at import time so consumers can tree-shake it cleanly when they disable observability via a build flag — the `getMeter` call is gated behind the first invocation of `getSiteInstruments`.
|
||||
66
docs/api-stack/api/api-plugin-site/src/plugin/plugin.md
Normal file
66
docs/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.
|
||||
@@ -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,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`.
|
||||
48
docs/api-stack/api/api-plugin-site/src/schemas/domain.md
Normal file
48
docs/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).
|
||||
54
docs/api-stack/api/api-plugin-site/src/schemas/options.md
Normal file
54
docs/api-stack/api/api-plugin-site/src/schemas/options.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# `schemas/options.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the plugin-options schema and the `mergeConfig` / `DEFAULT_CONFIG` helpers used by `createSitePlugin(...)`. Every option is validated at the boundary so misconfigurations are caught at plugin construction time — not at first request — and consumers get a fully-typed resolved config rather than a `Partial<...>` they have to defend against. `DEFAULT_CONFIG` is `Object.freeze`'d so accidental mutation by an integrator can't poison the defaults for the next call.
|
||||
|
||||
## Features
|
||||
|
||||
- `SitePluginOptionsSchema` — Zod schema with sensible defaults for every key.
|
||||
- `SitePluginConfig` — resolved type after defaults are applied.
|
||||
- `SitePluginOptionsInput` — caller-input type (every key optional).
|
||||
- `DEFAULT_CONFIG` — frozen snapshot of the parsed empty input.
|
||||
- `mergeConfig(user?)` — pure merge helper that parses through Zod, never mutates defaults.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { mergeConfig } from "@suaveplan/api-plugin-site/schemas/options";
|
||||
|
||||
const config = mergeConfig({ maxSitesPerOrg: 25 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
SitePluginOptionsSchema,
|
||||
} from "@suaveplan/api-plugin-site/schemas/options";
|
||||
|
||||
const parsed = SitePluginOptionsSchema.safeParse(rawInput);
|
||||
if (!parsed.success) {
|
||||
throw new SiteError(SITE_CODES.INVALID_OPTIONS, "bad config", {
|
||||
cause: parsed.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Discoverability:
|
||||
console.log("default cap:", DEFAULT_CONFIG.maxSitesPerOrg);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SitePluginOptionsSchema: ZodObject` — defaulted shape.
|
||||
- `SitePluginOptionsInput`, `SitePluginConfig` — inferred input/output types.
|
||||
- `DEFAULT_CONFIG: Readonly<SitePluginConfig>` — `Object.freeze`'d snapshot.
|
||||
- `mergeConfig(user?: SitePluginOptionsInput): SitePluginConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Defaults match the upstream `DEFAULT_CONFIG` literal so consumer behaviour is byte-identical for the common defaults.
|
||||
- `routePrefix` defaults to `/api` to match other suaveplan plugins.
|
||||
- `mergeConfig` always returns a fresh object; tests assert that mutating its result never affects `DEFAULT_CONFIG` (see `options.test.ts`).
|
||||
- Negative or zero `maxSitesPerOrg` / `maxDomainsPerSite` are rejected by `.positive()`; production sites should size these via env-driven config.
|
||||
56
docs/api-stack/api/api-plugin-site/src/schemas/push.md
Normal file
56
docs/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.
|
||||
69
docs/api-stack/api/api-plugin-site/src/schemas/site.md
Normal file
69
docs/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.
|
||||
48
docs/api-stack/api/api-plugin-site/src/service/db-port.md
Normal file
48
docs/api-stack/api/api-plugin-site/src/service/db-port.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# `service/db-port.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares `SiteDb`, the structural database port that `SiteService` and `DomainResolverService` consume. suaveplan's foundation `DatabaseAdapter` (`@suaveplan/db`) only owns `query` / `execute`, so the typed CRUD surface (`create`, `update`) we need for `site` records is sourced from `@suaveplan/surrealdb`'s `SurrealAdapter`. Centralising the port here lets the services depend on a single structural shape rather than `SurrealAdapter` directly, which keeps the unit tests free of testcontainer requirements and keeps the production wiring honest: any `SurrealAdapter` instance satisfies this shape by construction.
|
||||
|
||||
## Features
|
||||
|
||||
- One structural interface — `SiteDb` — exposing `query` (positional bindings), `create` (typed), `update` (typed).
|
||||
- No runtime imports — pure type module.
|
||||
- Lets tests supply minimal fakes without crafting a full `DatabaseAdapter`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { SiteDb } from "@suaveplan/api-plugin-site/service/db-port";
|
||||
|
||||
const fake: SiteDb = {
|
||||
query: async () => [],
|
||||
create: async (_t, d) => ({ ...d, id: "site:1" }),
|
||||
update: async () => null,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { SiteDb } from "@suaveplan/api-plugin-site/service/db-port";
|
||||
|
||||
// Production wiring — the live SurrealAdapter satisfies SiteDb structurally.
|
||||
function wire(db: SurrealAdapter): SiteDb {
|
||||
return db;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SiteDb` —
|
||||
- `query<T>(sql, params?): Promise<T[]>` — positional binds (`$p0...`).
|
||||
- `create<T>(table, data): Promise<T & { id: string }>` — typed create.
|
||||
- `update<T>(table, id, data): Promise<T | null>` — typed merge.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `query` bindings are positional because the `DatabaseAdapter.query` signature is `(sql, params?: readonly unknown[])`. The site plugin's SurQL statements are written with `$p0`, `$p1`, ... accordingly.
|
||||
- The port deliberately omits transaction / migrate / connect surface — services don't need it. Adding new methods to the port is a breaking change and follows the OpenSpec workflow.
|
||||
- The port is structural — adopters do not need to extend or implement an interface explicitly.
|
||||
@@ -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,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.
|
||||
78
docs/api-stack/api/api-plugin-site/src/service/site.md
Normal file
78
docs/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.
|
||||
@@ -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.
|
||||
51
docs/api-stack/api/api-plugin-site/src/types/types.md
Normal file
51
docs/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).
|
||||
Reference in New Issue
Block a user