Typed client + generated types for the Semrush Project Engine API, plus a stateful Counterfact mock used by local dev and the cross-repo e2e harness.
src/— the published surface: the rawcreateSerenityProjectEngineApiClient(anopenapi-fetchclient over every generated operation) andcreateSerenityProjectEngineTransport, an intent-named facade that wraps the 35 in-spec operations spacecat-api-service consumes behind verb+resource methods with a single error seam. Consumers depend on the facade; the raw client stays available for the remaining operations. Plus generated types (src/generated/types.ts). This is the ONLY thing that ships (files: ["src"]).mock/— the stateful mock (store, factories, seeds, quota metering, bearer auth, Counterfact handlers, runner). NOT published (files: ["src"]ships onlysrc/, so the./mock/*subpath export resolves via the in-workspace symlink only, never the published registry tarball — intended: consumers use the mock as an in-monorepo dev dependency, not off npm). Boot withnpm run mock. Usage manual:docs/mock-usage.md(humans + agents — auth, full endpoint inventory, seeds, control routes, quota, troubleshooting). AI-unit quota (the disguised-405 the live API returns for an over-allocation) is inmock/quota.js, set via thePOST /__quotacontrol route orbuildSeed({ quota }), enforced on project create / prompt write / publish / resume. Pause/resume (mock/pause.js) is a pure decision table over the stored project — live keeps the state inis_pausedon the project read-view (overlay CR23), pause is IDEMPOTENT and resume 409s on a running project, and both 404 an unknown project id (all live-verified 2026-08-24). Bearer auth (mock/auth.js) is modelled like the live gateway — every real route needsAuthorization: Bearer <token>(presence, not validity) or returns401 { detail: 'Not authenticated' }; the__*control routes are exempt. The gate is injected onto every handler at the materialization seam byinjectAuthGuardinmock/run.js(so no handler can forget it). A new file imported bymock/context.jsis materialized automatically —LIB_FILESinmock/run.jsis auto-derived from themock/*.jsfiles, so there is no list to maintain. For the containerized HTTPS form of the mock (a GHCR image consumed by cross-repo e2e like spacecat-api-service, which requires anhttps:origin), seedocs/mock-docker.md—Dockerfile+Caddyfile(TLS terminator) +docker-entrypoint.sh+docker-compose.yml, published by.github/workflows/project-engine-client-mock-image.yamlon each release tag.spec/— the vendored swagger +spec/overlays/corrections.yaml(corrections).
Coverage: this package enforces branches: 100 in .nycrc.json — stricter than the
monorepo-standard 97% (root CLAUDE.md) — and excludes mock/counterfact/routes/** and
mock/run.js (materialized handlers + the server launcher need a live server, validated by the
E2E). Everything else, including mock/inject-auth-guard.js, hits 100%.
Every hand-authored .js file under src/ and mock/ MUST start with // @ts-check (right
after the license header). They are type-checked by npm run test:types
(tsc -p tsconfig.json — the same config editors use, which globs src/, mock/, test/; add a file, it's
covered, no list to maintain). Treat a test:types failure like a compile error; it is the gate
that catches fixture/JSDoc drift from the overlayed schema at build time (it has already caught
real bugs in the client's retry code).
- When you add a
.jsfile undersrc/ormock/, add// @ts-checkand maketscpass. - Node built-ins are typed (
types: ["node"]);globalThis.cryptois typed via@types/node(thetsconfig.jsonlibis["esnext"], nodom). - Prefer fixing the JSDoc/types over
@ts-ignore. If a cast is unavoidable, use an inline/** @type {...} */ (expr)and explain why.
The one exception is the Counterfact route handlers under mock/counterfact/routes/**: they
run against Counterfact's untyped $ context, so they deliberately OMIT // @ts-check (they are
still in the tsc program, just not error-reported). Don't add // @ts-check there unless you also
type $. Test specs (test/**/*.test.js, *.e2e.js) are in the program too; the typed surface
is guarded by the .ts type-tests under test/types/ rather than per-spec @ts-check.
Build seed/fixture entities with the typed factories in mock/factories.js — createProjectMock,
createProjectAiModelMock, createAiModelMock, createPromptMock, createBenchmarkMock,
createBrandUrlMock, createLanguageMock, createTagNodeMock, createAIOTagMock, createBrandTopicMock,
createBasicResponseMock, createInitStatusMock, createCiCompetitorMock — each
(Partial<T>) => T typed against components['schemas'][...] (the
mock factory pattern). Never
hand-write entity literals: they drift from the spec (an early seed had grown a workspace_id that
isn't in ProjectResponse). test/types/factories.type-test.ts proves the enforcement (the
workspace_id drift and a wrong field type are @ts-expect-error canaries). Use real UUIDs.
This applies to the Counterfact route handlers too, not just seeds/tests. The factories are
exposed on the per-request context as context.factories, so a handler builds every response
entity through them — context.factories.createBenchmarkMock({ brand_name, domain, ... }), the
catalog handlers map their data rows through createLanguageMock / createAiModelMock /
createBrandTopicMock — rather than emitting an inline literal that the untyped handler (the
@ts-check exception) couldn't catch drifting. The factory is the single, tsc-checked source of
truth for each shape. This holds for EVERY handler that returns an entity, including the trivial
envelope shapes: getInitStatus builds a createInitStatusMock and updateCiCompetitors maps
through createCiCompetitorMock — there are no inline-literal entity exceptions left.
The empty-body action acks (publish, batch-delete, update-benchmark) are NOT entities — they
mirror the live gateway's content-length: 0 ack, so they return context.emptyAck(202) from
mock/responses.js rather than a factory. emptyAck is a raw { status, body: '', contentType }
envelope (like auth.js's 401 and quota.js's 405): the explicit contentType makes Counterfact
skip response content-negotiation, which otherwise 406s an empty-body 2xx under Accept: application/json (the header the real serenity transport always sends). A bare { status: 204 }
delete needs no helper — Counterfact serves 204 No Content without negotiating.
spec/overlays/corrections.yaml holds all corrections to the vendored swagger. The vendored
spec/*.yaml is never hand-authored: the ONLY thing that lands in it is what Semrush ships, either
a whole document or a delta spliced in verbatim and re-locked with npm run spec:lock (the README's
"Spec source" section has the procedure and the deep-compare that evidences it). Everything else —
including a path Semrush has deployed but not yet documented — is a CRn action. Add one +
regenerate (npm run generate; or
npm run spec:convert && npm run spec:overlay for just the mock's build/openapi3.json). CR5
marks always-present response fields required so fixtures are enforced — keep any required
claim faithful to what the live API actually returns AND what the mock's own create handlers
populate, or Counterfact response validation / the create paths will break.