Files
2026-05-27 23:29:53 +01:00

18 KiB

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<SiteErrorCode>. 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.<KEY>"

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, the four SITE_STATE_* constants, and MIDDLEWARE_RECOMMENDATIONS plus the MiddlewareRecommendation type.

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"


Requirement: Middleware composition recommendations

The package SHALL expose MIDDLEWARE_RECOMMENDATIONS — a readonly MiddlewareRecommendation[] constant describing the recommended rate-limit window, rate-limit ceiling, and audit category per documented site route group (public:resolve, public:read, private:write). The recommendations SHALL be pure data; the site plugin SHALL NOT bind the recommended middleware itself — consumers compose them via the host app's app.use(prefix, middleware) Hono pattern. MiddlewareRecommendation SHALL be re-exported from the package barrel.

Scenario: bundle covers all three route groups

  • WHEN a caller imports MIDDLEWARE_RECOMMENDATIONS
  • THEN the array contains exactly three entries whose routeGroup fields are "public:resolve", "public:read", and "private:write"

Scenario: private:write carries the tightest limit

  • WHEN a caller picks the entry where routeGroup === "private:write"
  • THEN its rateLimit.limit is strictly less than the entry where routeGroup === "public:read" AND its auditCategory is "data.mutation"

Verified by: src/integration/middleware-recommendations.test.ts::"exposes three route groups" AND src/integration/middleware-recommendations.test.ts::"private:write is tightest and audited as data.mutation"


Requirement: Host-app middleware composition pattern is integration-tested

The package SHALL ship an integration test under src/integration/middleware-composition.integration.test.ts that boots a real Hono app composing (a) createAuditMiddleware from @suaveplan/api-plugin-audit mounted globally, (b) rateLimit from @suaveplan/api-plugin-rate-limit mounted on the site prefix, and (c) createPrivateSiteRoutes from this package mounted under the same prefix. The test SHALL exercise a POST /sites write call and SHALL assert that (1) the site row is persisted (site behaviour unchanged by middleware composition), (2) the response carries at least one X-RateLimit-* header (rate-limit observed the request), and (3) exactly one AuditEvent is recorded in the in-memory audit sink (audit observed the request).

Scenario: composed write path preserves site behaviour, surfaces rate-limit headers, and records one audit event

  • WHEN the composed Hono app receives POST /sites with a valid body and a forwarded-for IP header
  • THEN the response status is 201, res.headers.get("X-RateLimit-Limit") is non-null, and the MemoryAuditAdapter snapshot contains exactly one event whose details.path === "/sites" and details.method === "POST"

Verified by: src/integration/middleware-composition.integration.test.ts::"composes audit + rate-limit + site routes in the documented order"


Requirement: Package-level integration guide

The package SHALL ship docs/middleware-integration.md documenting the canonical Hono app.use(prefix, middleware) composition order (audit outermost → rate-limit middle → site routes innermost), the rationale for that order (audit must observe rate-limit denials; rate-limit must precede expensive handlers), and a copy-pasteable end-to-end example covering all three plugins. The README SHALL link to this guide from a "Middleware composition" subsection.

Scenario: guide exists and exceeds 200 words

  • WHEN the docs-check gate inspects docs/middleware-integration.md
  • THEN the file exists, contains the words app.use, audit, rate-limit, and site, and exceeds 200 words excluding code blocks

Verified by: src/integration/middleware-recommendations.test.ts::"docs/middleware-integration.md exists and documents the composition order"