15 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_FAILEDand 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
SiteErrorwhosecode === SITE.INVALID_OPTIONSand whosecauseis theZodError
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"anderroris the error's message
Scenario: resolveOrThrow throws SiteError on miss
- WHEN
resolveOrThrow("nope")runs against an empty result - THEN a
SiteErrorwithcode === "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
createruns against an organization already atmaxSitesPerOrg: 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 withdomains: ["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
EventBusLikereceives asite:settings:updatedpayload withsiteIdandchangedFields: ["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:8080arrives and the resolver hits - THEN
c.get("siteId"),c.get("organizationId"),c.get("site"), andc.get("organization")are populated
Scenario: empty host falls through
- WHEN the middleware is invoked against a context whose
req.header("host")returnsundefined - 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.comruns and the resolver hits - THEN status is
200and the body is{success: true, site, organization}
Scenario: site creation 201
- WHEN
POST /sitesruns with a valid body - THEN status is
201and the body is the created site
Scenario: site creation 400 on bad body
- WHEN
POST /sitesruns 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
onInitruns withoutdbregistered onServiceRegistry - THEN a
SiteError(SITE.SERVICE_NOT_AVAILABLE)is thrown
Scenario: route prefix collision raises ROUTE_PREFIX_COLLISION
- WHEN the host
appalready exposes a route under${routePrefix}/sites/*andonReadyruns - THEN a
SiteError(SITE.ROUTE_PREFIX_COLLISION)is thrown
Scenario: organization:created auto-creates default site
- WHEN the plugin is constructed with
autoCreateDefaultSite: trueand the event bus emitsorganization: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:failedwithorganizationId
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
MeterProvideris installed andresetSiteInstrumentsis called - THEN the next
getSiteInstruments()produces a new bundle andforceFlushcollects 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"