# Capability Spec Delta: api-plugin-site (add-api-plugin-site) **Package**: `@suaveplan/api-plugin-site` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Stable SITE.* error registry The package SHALL register exactly 14 stable error codes under the `"SITE"` namespace via `defineCodes("SITE", [...])`, exporting `SITE_CODES` and `SiteError extends SuaveplanError`. Every code SHALL be reachable from at least one production code path; deletion to satisfy a coverage gate is forbidden. #### Scenario: registry declares the 14 canonical codes - **WHEN** a caller imports `SITE_CODES` - **THEN** the literal values include `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` and every value is namespaced `"SITE."` #### Scenario: SiteError preserves cause + code - **WHEN** `new SiteError(SITE_CODES.SITE_NOT_FOUND, "missing", { cause })` is constructed - **THEN** `error.code === "SITE.SITE_NOT_FOUND"`, `error.siteCode === error.code`, `error.cause === cause` **Verified by:** `src/error-codes/error-codes.test.ts::"declares all 14 canonical SITE.* codes"` AND `src/error-codes/error-codes.test.ts::"SiteError preserves code, message, cause, and siteCode alias"` --- ### Requirement: Zod-validated plugin-options boundary The package SHALL expose `SitePluginOptionsSchema` with defaults for every option and SHALL reject non-positive numeric caps and empty `defaultDomainPattern` at Zod parse time. `createSitePlugin` SHALL fail with `SiteError(SITE.INVALID_OPTIONS)` carrying the `ZodError` as `cause` when called with bad input. `DEFAULT_CONFIG` SHALL be frozen. #### Scenario: defaults applied on empty input - **WHEN** `SitePluginOptionsSchema.parse({})` is invoked - **THEN** the result is `{maxSitesPerOrg: 10, maxDomainsPerSite: 5, autoCreateDefaultSite: true, defaultDomainPattern: "{slug}.suaveplan.app", enableDomainResolution: true, enablePushNotifications: true, enableSiteSearch: true, routePrefix: "/api"}` #### Scenario: invalid options fail with SITE.INVALID_OPTIONS - **WHEN** `createSitePlugin({ maxSitesPerOrg: -1 } as never)` is invoked - **THEN** it throws a `SiteError` whose `code === SITE.INVALID_OPTIONS` and whose `cause` is the `ZodError` **Verified by:** `src/schemas/options.test.ts::"applies all defaults when called with empty input"` AND `src/plugin/plugin.test.ts::"rejects invalid options with SITE.INVALID_OPTIONS"` --- ### Requirement: Site / domain / push Zod schemas The package SHALL inline the canonical Zod surface for sites, domains, and push configs (`SiteSchema`, `CreateSiteSchema`, `UpdateSiteSchema (.strict)`, `SitePublicInfoSchema`, `SiteIdParamSchema`, `DomainSchema`, `AddDomain*Schema`, `RemoveDomain*Schema`, `DomainResolveParam/ResponseSchema`, `ApplePushConfigSchema`, `FirebasePushConfigSchema`, `PushConfigSchema`, `PushConfigValidationResponseSchema`). #### Scenario: CreateSiteSchema rejects empty domain list - **WHEN** `CreateSiteSchema.safeParse({ organization_id, name, domains: [] })` - **THEN** `result.success === false` #### Scenario: UpdateSiteSchema rejects unknown keys - **WHEN** `UpdateSiteSchema.safeParse({ name: "x", sneaky: 1 })` - **THEN** `result.success === false` #### Scenario: PushConfigSchema requires at least one provider - **WHEN** `PushConfigSchema.safeParse({})` - **THEN** `result.success === false` **Verified by:** `src/schemas/site.test.ts::"rejects empty domain list"` AND `src/schemas/site.test.ts::"rejects unknown keys via .strict()"` AND `src/schemas/push.test.ts::"requires at least one provider"` --- ### Requirement: Domain canonicalisation parity with database The package SHALL expose `normalizeDomain(input)` which lower-cases, trims, strips `http(s)://`, strips leading `www.`, and strips a single trailing `/`. The semantics SHALL match the SurrealDB `fn::site::domain::normalize` function so application-tier and database-tier writers agree on the canonical form. #### Scenario: composed prefix normalisation - **WHEN** `normalizeDomain("HTTPS://WWW.Acme.Example.Com/")` - **THEN** result is `"acme.example.com"` **Verified by:** `src/service/normalize-domain.test.ts::"strips composed prefixes"` --- ### Requirement: DomainResolverService — envelope + observability `DomainResolverService.resolve(domain)` SHALL return `{success, site?, organization?, error?, code?}`. On hit: `success: true` with `site` + `organization`. On miss: `success: false`, `error: "Domain not found"`, `code: SITE.DOMAIN_NOT_FOUND`. On driver throw: `success: false`, message preserved, `code: SITE.RESOLUTION_FAILED`. Every call SHALL increment `site.domain_resolutions_total{outcome}` and record `site.domain_resolution_duration_ms`. `resolveOrThrow` SHALL throw `SiteError` carrying the same code. #### Scenario: miss envelope - **WHEN** the underlying query returns `[]` - **THEN** `resolve("nope")` returns `{success: false, error: "Domain not found", code: "SITE.DOMAIN_NOT_FOUND"}` #### Scenario: driver error envelope - **WHEN** the underlying query throws an `Error` - **THEN** the returned envelope's `code === "SITE.RESOLUTION_FAILED"` and `error` is the error's message #### Scenario: resolveOrThrow throws SiteError on miss - **WHEN** `resolveOrThrow("nope")` runs against an empty result - **THEN** a `SiteError` with `code === "SITE.DOMAIN_NOT_FOUND"` is thrown **Verified by:** `src/service/domain-resolver.test.ts::"returns DOMAIN_NOT_FOUND code on empty result"` AND `src/service/domain-resolver.test.ts::"returns RESOLUTION_FAILED code when db throws Error"` AND `src/service/domain-resolver.test.ts::"throws DOMAIN_NOT_FOUND on miss"` --- ### Requirement: SiteService — typed CRUD + cap enforcement + event emission `SiteService` SHALL expose `create`, `createDefaultSite`, `getById`, `requireById`, `listByOrganization`, `countByOrganization`, `update`, `updateSettings`, `updateState`, `addDomain`, `removeDomain`, `archive`, `getActive`, `search`. `create` SHALL enforce `maxSitesPerOrg` (raises `SITE.SITE_LIMIT_EXCEEDED`) and `maxDomainsPerSite` (raises `SITE.DOMAIN_LIMIT_EXCEEDED`). `addDomain` SHALL persist the new domain (upstream bug fix) and reject duplicates with `SITE.DOMAIN_ALREADY_CLAIMED`. `removeDomain` SHALL refuse to leave the site with zero domains via `SITE.LAST_DOMAIN_REMOVAL`. `updateSettings` SHALL emit `site:settings:updated` natively (no monkey-patching). #### Scenario: create enforces site cap - **WHEN** a second `create` runs against an organization already at `maxSitesPerOrg: 1` - **THEN** a `SiteError(SITE.SITE_LIMIT_EXCEEDED)` is thrown #### Scenario: addDomain persists new domain - **WHEN** `addDomain(siteId, "B.Example.Com")` is called against a site with `domains: ["a.example.com"]` - **THEN** the returned site has `domains: ["a.example.com", "b.example.com"]` AND the underlying row is mutated #### Scenario: addDomain rejects duplicates - **WHEN** `addDomain(siteId, "A.Example.Com")` is called against a site already containing `"a.example.com"` - **THEN** a `SiteError(SITE.DOMAIN_ALREADY_CLAIMED)` is thrown #### Scenario: removeDomain refuses last - **WHEN** `removeDomain(siteId, "a.example.com")` is called against a site with one domain - **THEN** a `SiteError(SITE.LAST_DOMAIN_REMOVAL)` is thrown #### Scenario: updateSettings emits site:settings:updated - **WHEN** `updateSettings(siteId, { features: ["beta"] })` succeeds - **THEN** the injected `EventBusLike` receives a `site:settings:updated` payload with `siteId` and `changedFields: ["features"]` **Verified by:** `src/service/site.test.ts::"throws SITE_LIMIT_EXCEEDED when over the cap"` AND `src/service/site.test.ts::"addDomain persists the normalized domain"` AND `src/service/site.test.ts::"addDomain throws DOMAIN_ALREADY_CLAIMED on duplicates"` AND `src/service/site.test.ts::"removeDomain refuses LAST_DOMAIN_REMOVAL"` AND `src/service/site.test.ts::"updateSettings merges, persists, and emits site:settings:updated"` --- ### Requirement: Hono middleware factory `createDomainResolverMiddleware(resolver)` SHALL return a Hono middleware that reads `Host` then `X-Forwarded-Host`, normalises via `normalizeDomain`, and on a hit injects `site` / `organization` / `siteId` / `organizationId` into the request context. On miss or absent host the middleware SHALL fall through silently. #### Scenario: hit injects four context keys - **WHEN** a request with `Host: https://www.Example.Com:8080` arrives and the resolver hits - **THEN** `c.get("siteId")`, `c.get("organizationId")`, `c.get("site")`, and `c.get("organization")` are populated #### Scenario: empty host falls through - **WHEN** the middleware is invoked against a context whose `req.header("host")` returns `undefined` - **THEN** `next()` is called once and no site key is set **Verified by:** `src/middleware/domain-resolver.test.ts::"injects site + organization on a hit"` AND `src/middleware/domain-resolver.test.ts::"falls through when host header is empty (direct unit call)"` --- ### Requirement: Public + private OpenAPI route trees `createPublicSiteRoutes` SHALL expose `GET /sites/resolve/{domain}` (returning the resolution envelope) and `GET /sites/{id}/public` (returning the sanitised public projection). `createPrivateSiteRoutes` SHALL expose `POST /sites`, `PATCH /sites/{id}`, `POST /sites/{id}/domains`, `DELETE /sites/{id}/domains`. All bodies SHALL be Zod-validated through `@hono/zod-openapi`. #### Scenario: domain resolution endpoint returns 200 envelope - **WHEN** `GET /sites/resolve/example.com` runs and the resolver hits - **THEN** status is `200` and the body is `{success: true, site, organization}` #### Scenario: site creation 201 - **WHEN** `POST /sites` runs with a valid body - **THEN** status is `201` and the body is the created site #### Scenario: site creation 400 on bad body - **WHEN** `POST /sites` runs with an empty body - **THEN** status is `>= 400` **Verified by:** `src/routes/public-routes.test.ts::"resolves a domain to a site envelope"` AND `src/routes/private-routes.test.ts::"creates a site via POST /sites"` AND `src/routes/private-routes.test.ts::"rejects POST /sites with bad input via Zod 400"` --- ### Requirement: Plugin lifecycle + auto-bootstrap `createSitePlugin` SHALL produce a `PluginDescriptor` with `name: "site"`, `version: "0.1.0"`, `optionalDependencies: ["database", "auth", "events"]`. `onInit` SHALL resolve `db` from `ServiceRegistry` (raising `SITE.SERVICE_NOT_AVAILABLE` if missing), construct + register `siteService`, `domainResolver`, `domainResolverMiddleware`, optionally subscribe to `organization:created`, and emit `site:initialized`. `onReady` SHALL resolve `app`, detect route-prefix collisions (`SITE.ROUTE_PREFIX_COLLISION`), mount the route trees, and emit `site:ready`. `onClose` SHALL drop the `organization:created` listener. #### Scenario: missing db raises SERVICE_NOT_AVAILABLE - **WHEN** `onInit` runs without `db` registered on `ServiceRegistry` - **THEN** a `SiteError(SITE.SERVICE_NOT_AVAILABLE)` is thrown #### Scenario: route prefix collision raises ROUTE_PREFIX_COLLISION - **WHEN** the host `app` already exposes a route under `${routePrefix}/sites/*` and `onReady` runs - **THEN** a `SiteError(SITE.ROUTE_PREFIX_COLLISION)` is thrown #### Scenario: organization:created auto-creates default site - **WHEN** the plugin is constructed with `autoCreateDefaultSite: true` and the event bus emits `organization:created` - **THEN** the service's underlying store contains a site for that organization id #### Scenario: createDefaultSite failure emits site:bootstrap:failed - **WHEN** the organization is already at its site cap and the auto-bootstrap listener fires - **THEN** the event bus emits `site:bootstrap:failed` with `organizationId` **Verified by:** `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE when db is missing"` AND `src/plugin/plugin.test.ts::"detects route prefix collisions and throws ROUTE_PREFIX_COLLISION"` AND `src/plugin/plugin.test.ts::"auto-creates a default site on organization:created"` AND `src/plugin/plugin.test.ts::"emits site:bootstrap:failed when createDefaultSite throws"` --- ### Requirement: OTel instrument bundle The package SHALL expose `getSiteInstruments` returning a lazily-built bundle of five OTel instruments: `sitesCreatedTotal`, `sitesUpdatedTotal`, `domainResolutionsTotal`, `domainResolutionDurationMs`, `bootstrapFailedTotal`. The bundle SHALL be cached at module scope and `resetSiteInstruments` SHALL clear the cache so tests can install a fresh `MeterProvider`. #### Scenario: instruments cached across calls - **WHEN** `getSiteInstruments()` is invoked twice in succession without a reset - **THEN** the second call returns the same object instance as the first #### Scenario: reset rebinds against a fresh MeterProvider - **WHEN** a fresh `MeterProvider` is installed and `resetSiteInstruments` is called - **THEN** the next `getSiteInstruments()` produces a new bundle and `forceFlush` collects metrics from it **Verified by:** `src/otel/instruments.test.ts::"getSiteInstruments returns a cached bundle"` AND `src/otel/instruments.test.ts::"resetSiteInstruments rebinds against a fresh MeterProvider"` --- ### Requirement: ServiceRegistry augmentation The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with three slots — `siteService`, `domainResolver`, `domainResolverMiddleware` — plus the cumulative host slots `app: OpenAPIHono` and `db: SurrealAdapter`. Importing the package barrel SHALL be sufficient to surface the augmentation. #### Scenario: descriptor lists optional database / auth / events dependencies - **WHEN** `createSitePlugin()` returns a descriptor - **THEN** `descriptor.optionalDependencies === ["database", "auth", "events"]` **Verified by:** `src/plugin/plugin.test.ts::"descriptor name is 'site' and version is '0.1.0'"` --- ### Requirement: Public surface re-exports The package barrel SHALL re-export the canonical public surface: `SITE_CODES`, `SiteError`, `DEFAULT_CONFIG`, `mergeConfig`, `SiteService`, `DomainResolverService`, `normalizeDomain`, `createDomainResolverMiddleware`, `createPublicSiteRoutes`, `createPrivateSiteRoutes`, `createSitePlugin`, `getSiteInstruments`, `resetSiteInstruments`, `validatePushConfig`, `SiteSchema`, `DomainSchema`, plus the four `SITE_STATE_*` constants. #### Scenario: re-export surface count - **WHEN** the spec-conformance test imports every documented symbol from the package barrel - **THEN** every symbol resolves and the import does not throw **Verified by:** `src/api-plugin-site.spec-conformance.test.ts::"re-exports every documented symbol"` ---