Skip to content

feat(project-engine-client): stateful mock core — store, resource ops, seeds (LLMO-5460)#1665

Open
aliciadriani wants to merge 9 commits into
llmo-5461-project-engine-client-wrapperfrom
llmo-5460-project-engine-mock
Open

feat(project-engine-client): stateful mock core — store, resource ops, seeds (LLMO-5460)#1665
aliciadriani wants to merge 9 commits into
llmo-5461-project-engine-client-wrapperfrom
llmo-5460-project-engine-mock

Conversation

@aliciadriani

@aliciadriani aliciadriani commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What

The stateful Project Engine Counterfact mockLLMO-5460 (epic LLMO-5459), work-plan steps 5–8. A generic in-memory store + the confirmed stateful resource ops, the live Counterfact runner that wires them into per-path handlers, named seeds, a test-only reset route, and an in-package E2E that drives the real client against a booted mock.

Stacked on #1661#1660. Base is the wrapper branch; GitHub retargets to main as the stack merges.

Spike outcome (confirmed against both consumers)

Grepped the real consumers to set the AC floor (docs/mock-statefulness.md):

  • spacecat-api-service is the only Project Engine consumer; llmo-data-retrieval-service makes zero calls.
  • The write-then-read spine is projects, ai_models (per project), prompts (aio, per project). The prompt slice was reconciled against the consumer's actual call sites (rest-transport.js) and the spec: create is POST .../aio/prompts/tagged, list is POST .../aio/prompts/by_tags, delete is DELETE .../aio/prompts — not the POST /aio/prompts the spike first guessed (delete-only in the spec). publish and the GET /v1/ai_models / GET /v1/languages lookups stay on Counterfact's auto-generated response.

Contents

  • src/mock/store.js — generic InMemoryStore: collection-keyed CRUD + seed/reset, deep-clone on every read/write (a loaded seed is never mutated; reset() restores a pristine copy).
  • src/mock/stateful.js — the confirmed stateful set as pure ops over the store (per-workspace/per-project collection scoping). No Counterfact/HTTP coupling, so unit-tested directly.
  • src/mock/seeds.js — named seed sets (empty-workspace, workspace-with-data), DEFAULT_SEED, SEED_IDS. Seed shapes match the spec response models (e.g. prompts as AIOPromptWithStatus).
  • src/mock/context.js — the Counterfact per-path Context: wires the pure store + ops to a seed selected at startup and exposes reset().
  • src/mock/run.js — the live mock runner. Materializes the committed handlers into a gitignored .counterfact/ tree (as .ts, so the transpiler emits loadable .cjs) and launches Counterfact with --serve (no appended spec stubs). npm run mock.
  • src/mock/counterfact/routes/** — the stateful per-path handlers (projects, ai_models, aio prompts tagged/by_tags/delete) plus the test-only __reset control route.
  • test/mock/*.test.js — unit tests for store, stateful ops, seeds, and context.
  • test/e2e/project-engine-mock.e2e.js — boots the live mock and drives it through the real client; gated behind MOCK_E2E=1, outside the default npm test glob. Exercises the prompt write-then-read spine (tagged → by_tags) with response validation on, so the response envelopes are confirmed spec-valid.
  • test/types/index.type-test.ts — compile-only guard that the public type surface stays assignable to Client<paths> and never degrades to any (npm run test:types).
  • CI — a path-gated E2E (project-engine mock) job (type-surface check + live E2E), scoped via a merge-base diff so it only runs when this package changes.
  • docs/mock-statefulness.md — the spike decision rule + confirmed inventory.

Validation

  • npm test — 51 passing, 100% statements / branches / functions / lines
  • npm run lint — clean
  • npm run test:types — clean
  • npm run test:e2e — 8 passing against the live mock (response validation on)

Follow-up (separate PR)

Adopting the mock into spacecat-api-service's own E2E/local-dev (replacing its Project Engine calls against this mock) lands with the adoption PR — out of scope here, which delivers the mock and its self-contained E2E.

🤖 Generated with Claude Code

Alicia Adriani and others added 3 commits June 11, 2026 11:24
…ul mock

Port the scaffold's generic, resource-agnostic InMemoryStore to JS + JSDoc
(mocha + chai). This is the statefulness primitive for the Counterfact mock:
collection-keyed CRUD plus seed/reset, with deep cloning on every read/write so
a loaded seed snapshot is never mutated and reset() restores a pristine copy.

Knows nothing about specific resources — the stateful handlers (projects,
ai_models, prompts per the recommended first cut) plug their collections into it
in a follow-up commit; non-stateful endpoints keep Counterfact's auto-generated
schema response.

100% coverage; lint clean.

LLMO-5460 (epic LLMO-5459)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e mock

Confirm the spike against both consumers: spacecat-api-service is the only Project
Engine consumer (llmo-data-retrieval-service makes zero calls), and the write-then-read
spine is exactly projects, ai_models (per project), and prompts (aio, per project) —
the recommended first cut. Documented as the AC floor in docs/mock-statefulness.md.

- src/mock/stateful.js: the confirmed stateful set as pure operations over InMemoryStore
  (per-workspace/per-project collection scoping + the CRUD each group needs). No Counterfact
  or HTTP coupling, so it is unit-tested on its own; the runner adapts these into per-path
  handlers and maps results into spec-valid response envelopes.
- src/mock/seeds.js: named seed sets (empty-workspace, workspace-with-data) keyed to the same
  collections, plus DEFAULT_SEED and SEED_IDS. Loaded via store.load(...); store.reset()
  restores between tests.

100% coverage; lint clean.

LLMO-5460 (epic LLMO-5459)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Materializes the committed stateful handlers into a gitignored .counterfact
scratch tree and launches Counterfact against the Project Engine spec. The
shared in-memory store is wired in via a generated root Context, seeded by
MOCK_SEED, with a __reset control route for E2E isolation.

Validated end-to-end against a running server: projects list/create/get/
patch/delete, ai_models list/add, v2 aio prompts create, and __reset
restoring seed state.

Key runner details discovered against the live server:
- Materialize the whole tree as .ts so Counterfact's transpiler emits .cjs
  with matching rewritten specifiers; .js sources emit CJS content under a
  "type": "module" package and fail to load.
- Pass --serve explicitly so Counterfact does not default-enable codegen,
  which would append typed random() stubs onto our handlers (duplicate
  GET/POST). Transpile + load still run under --serve.
- --no-validate-request: the spec marks Auth-Data-Jwt required but Node
  lowercases inbound header names, so validation 400s every request.

run.js and the materialized handlers require a live server, so they are
excluded from coverage; the unit-testable Context/store/ops keep 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

Add a Mock section covering MOCK_PORT/MOCK_SEED, the base URL, the stateful
endpoints and the __reset control route, plus a note on how the runner
materializes handlers. Drop the stale "later PR" marker now that the mock ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani aliciadriani self-assigned this Jun 11, 2026
Alicia Adriani and others added 3 commits June 11, 2026 13:24
…t the live mock

Adds an env-gated (MOCK_E2E=1) E2E suite that boots the stateful Counterfact
mock via src/mock/run.js and drives it through the real client: projects
list/create/get/patch/delete, ai_models list/add, v2 aio prompts, and
__reset isolation between cases. Self-managed lifecycle (spawn in before,
process-group teardown in after).

Kept deliberately out of the fast path:
- file glob *.e2e.js (not *.test.js) so default `npm test` never loads it,
  preserving the unit suite's speed and 100% coverage with no live-server dep
- `npm run test:e2e` runs it in isolation (--no-package so the package.json
  mocha spec/reporters don't bleed in)
- a dedicated `E2E (project-engine mock)` CI job runs it, independent of the
  release gate
- root eslint override allows devDependencies in *.e2e.js (the import rule's
  built-in test glob only covers *.test.js)

Scoped to this package (client <-> mock contract); the api-service-level E2E
rides with the adoption PR (LLMO-5461 #4, blocked on publish).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t + robust teardown

Address review points on the live-mock E2E:

- CI scope: add a cheap `changes` guard job (plain git merge-base diff, no
  third-party action) that the E2E job `needs`/`if`-gates, so Counterfact only
  boots when the project-engine-client package actually changed — not on every
  monorepo push. `on.paths` can't be used here: the workflow triggers on push,
  so a path filter would gate `test`/`release` too.
- Ports: replace the hardcoded 4099 with an OS-assigned free port (MOCK_E2E_PORT
  still honoured for manual pinning) to avoid collisions across runs/jobs.
- Teardown: after() now awaits the detached process group's exit (bounded) so the
  port is freed and no mock server is orphaned, including when before()/a test
  throws (mocha still runs after-hooks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typed client is this package's deliverable, but the public surface
(hand-written src/index.d.ts) was unguarded: it can silently drift from the
generated `paths` and degrade to `any`, and both the unit and E2E suites are
type-blind (plain JS). Per repo convention every sibling package hand-writes
its .d.ts and none emit from JSDoc, so rather than diverge to a checkJs build,
add an additive consumer type-test:

- test/types/index.type-test.ts: compile-only fixture asserting the factory
  returns Client<paths>, a generated operation stays typed, and bogus
  path/component keys are rejected (an @ts-expect-error canary that fails the
  build if the surface collapses to `any`).
- tsconfig.types-test.json: tsc --noEmit, bundler resolution so `.js` import
  specifiers resolve to the .d.ts / generated .ts.
- `npm run test:types`; pinned typescript devDep so a transitive-hoist change
  can't silently disable the guard.
- CI: runs in the existing path-gated project-engine job (tsc-only, no server),
  so it only fires when the package or its generated types change.

Verified the guard fails on real drift (client => any -> TS2578 unused-directive;
factory return change -> TS2322) and is clean otherwise.

Surfaced finding (tracked separately, not fixed here): the generated spec marks
`Auth-Data-Jwt` as a required per-call header, so TS consumers must pass it even
though the client injects it at runtime — a DX wart in the typed surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

Type-surface finding (surfaced by the new CI guard)

The Type surface check (npm run test:types, added in this PR) caught a real contract/implementation mismatch within minutes of landing — exactly the argument for guarding the type surface rather than deferring it.

The mismatch:

  • The generated paths mark Auth-Data-Jwt as a required per-operation header — faithful to the Semrush API contract.
  • But the client owns that header via middleware and sets it with headers.set(...), so any value a consumer passes is silently overwritten.

Why it matters (footgun, not cosmetics): the client's public type (Client<paths>) forces every call site in llmo + api-service to pass an Auth-Data-Jwt token that does nothing. It looks load-bearing, so someone will eventually pass a "real" token there and lose time discovering it's ignored. The E2E suite never caught this (plain JS, type-blind); the type-test did.

Options:

  1. (leaning) Narrow the client's exported type — a mapped type over paths that drops the injected Auth-Data-Jwt from each operation's params.header, so the public surface reflects that the client supplies it.
  2. Accept it — rejected: keeps a recurring footgun in every consumer.

Hard constraint (option 1): do the Omit at the client's exported type only. Do not touch the vendored spec, src/generated/types.ts, or the Pydantic models — those are the honest API contract and the spec-is-the-contract gate must hold.

Parked for the #4 adoption (where real call sites make the cost and the exact shape-to-omit concrete; the transform is a non-trivial recursive mapped type). The client type itself lives in #1661. The type-test fixture moves in lockstep — whatever's decided, test/types/index.type-test.ts updates with it (drop the header: { 'Auth-Data-Jwt': ... } line from the typed-call assertion when the surface is narrowed), or the guard will just re-flag the old surface.

Tracked as an LLMO-5461 sub-task linked to the #4 adoption work.

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline comments below carry the proposed fix for each finding from the review.

Process — Should Fix (not inline): the PR description is stale relative to the branch. The body says step 5 (Counterfact runner) and step 8 (E2E + CI wiring) are "Deferred to a follow-up PR," but this branch already contains run.js, the route handlers, __reset.js, the full E2E suite, and the new E2E (project-engine mock) CI job. The "Contents" list also omits context.js, run.js, the handlers, and the type-surface test. Please refresh the description to match the 24-file / +1579 diff so reviewers can size scope.

Fix: update the PR body — move steps 5 & 8 from "Deferred" to "Contents," and add context.js, run.js, the counterfact handlers, __reset.js, the E2E suite, and test/types/index.type-test.ts to the contents list.

Comment on lines +22 to +29
export function POST($) {
const { path, body, context } = $;
const created = context.ops.prompts.createMany(
{ workspaceId: path.id, projectId: path.project_id },
(body?.items ?? []).map((text) => ({ name: text })),
);
const first = created[0] ?? {};
return $.response[200].json({ id: first.id, name: first.name });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should Fix — this create handler is mounted on a path the spec and the consumer don't use.

The spec defines only delete on /aio/prompts (aio-delete-prompt-by-ids-v2) — there is no POST here. The real create is POST /aio/prompts/tagged (aio-create-prompts-with-tags) and the real read is POST /aio/prompts/by_tags (aio-list-prompts-by-tag-ids) — exactly what docs/mock-statefulness.md lists as the consumer's stateful prompt ops.

Consequences as written:

  • The E2E creates aio prompts (v2) case drives a non-spec POST, so there's no 200 response schema for Counterfact to validate the { id, name } envelope against — the "response validation stays on" guarantee doesn't cover this handler.
  • When spacecat-api-service runs against the mock, prompt create (tagged) and read (by_tags) hit Counterfact's stateless stubs, so the documented prompt write-then-read spine isn't actually stateful.

Fix: keep DELETE in this file, move create into a new aio/prompts/tagged.js, and add a aio/prompts/by_tags.js read handler (the store + ops already support both via createMany / list). Repoint the E2E case at /v2/.../aio/prompts/tagged.

// aio/prompts/tagged.js — POST create (operationId aio-create-prompts-with-tags)
export function POST($) {
  const { path, body, context } = $;
  const created = context.ops.prompts.createMany(
    { workspaceId: path.id, projectId: path.project_id },
    (body?.items ?? []).map((text) => ({ name: text })),
  );
  const first = created[0] ?? {};
  return $.response[200].json({ id: first.id, name: first.name });
}
// aio/prompts/by_tags.js — POST list/read (operationId aio-list-prompts-by-tag-ids)
// shape the envelope to match the spec's 200 response for this op
export function POST($) {
  const { path, context } = $;
  const items = context.ops.prompts.list({ workspaceId: path.id, projectId: path.project_id });
  return $.response[200].json({ items, page: 1, total: items.length });
}

If this is intentionally deferred to the adoption PR, please scope it out in the description and drop the POST /aio/prompts row from the README's stateful-endpoints table, which currently advertises it as "create prompts."

{ id: 'model-gpt4o', name: 'gpt-4o' },
],
[collectionKey('prompts', { workspaceId: WORKSPACE_ID, projectId: PROJECT_ID })]: [
{ id: 'prompt-1', text: 'What is the best running shoe?' },

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — seed/created prompt field mismatch. Seed prompts use text, but prompts.createMany stores { name } and the create handler returns { id, name }. If a by_tags read handler is added (see the prompts.js comment), seeded vs. created prompts will have inconsistent shapes. Align the seed to name:

Suggested change
{ id: 'prompt-1', text: 'What is the best running shoe?' },
{ id: 'prompt-1', name: 'What is the best running shoe?' },

const id = entity.id ?? InMemoryStore.#generateId();
/** @type {Entity} */
const stored = { ...entity, id };
this.#collection(name).set(id, stored);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (optional, no change required). create with an explicit existing id silently overwrites via Map.set (a real API would 409). Fine for a mock; flagging only because seeds use fixed ids, so a seed-id collision would be swallowed.

If you want collisions to surface during dev, the minimal guard is:

if (entity.id && this.#collection(name).has(entity.id)) {
  throw new Error(`duplicate id ${entity.id} in collection ${name}`);
}

Otherwise leave as-is — overwrite-on-duplicate is acceptable mock behaviour.

{ workspaceId: path.id, projectId: path.project_id },
{ model: { id: body.model_id }, prompts_count: 0 },
);
return $.response[200].json(created);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (no change required). This POST returns 200 while the projects create returns 201. CI response-validation is on and green, so 200 is what the spec declares for add ai_model — the inconsistency is spec-driven, not a bug. Noting only for awareness.

}

/** DELETE — batch-delete prompts (body: { ids }). */
export function DELETE($) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (no change required). prompts.removeMany (and ai_models.removeMany) compute a removed count that this handler discards — it always returns 204. The spec DELETE is 204, so this is correct; the count is exercised at the ops layer in stateful.test.js. Flagging only so the unused return value reads as deliberate.

aliciadriani pushed a commit that referenced this pull request Jun 11, 2026
…-Data-Jwt header

The generated `paths` mark `Auth-Data-Jwt` as a required per-operation header
(faithful to the API contract), but the client injects that header at runtime
via middleware (headers.set) and overwrites whatever a caller passes. So the
typed surface was forcing every consumer to pass a value that's always ignored —
a footgun (a "real" token passed there silently does nothing).

Narrow the *exported* client type only:
- Add a type-only mapped transform over the generated `paths` (ClientPaths) that
  removes `Auth-Data-Jwt` from each operation's header params. Auth-Data-Jwt is
  the sole header in the contract, so the emptied bag collapses to `never` —
  passing a header is now a compile error, not just optional, which actively
  closes the footgun. A genuine consumer header (if ever added) is preserved.
- Type SerenityProjectEngineApiClient / the factory return against ClientPaths
  instead of raw Client<paths>.

Generated types, the vendored spec, and the Pydantic models are untouched — they
remain the honest API contract; only the client surface hides the header it
supplies itself. Runtime (client.js, middleware) is unchanged.

Validated with a throwaway tsc harness (not committed): a consumer calls an op
without Auth-Data-Jwt (compiles), path params stay required, passing
Auth-Data-Jwt is rejected, and the bogus-path drift canary still trips. Lint
clean; unit suite 51 passing at 100% coverage (type-only change).

Guard reconciliation (cross-PR): the type-surface guard fixture lives in #1665
(test/types/index.type-test.ts) and currently asserts the un-narrowed surface
(it passes header: { 'Auth-Data-Jwt' }). That fixture must be updated when these
land together at main — see the follow-up note on #1665. Relates to the #4
adoption, where consumer call sites drop the header argument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ths (tagged/by_tags)

The stateful prompt handler was mounted on POST /aio/prompts, which the spec
defines as delete-only and no consumer calls — false coverage that also escaped
response validation (no 200 schema to check the envelope against). The real
spacecat-api-service prompt flow (rest-transport.js) is:

  - create: POST /aio/prompts/tagged  (body { prompts: { [tag]: [text] } })
  - list:   POST /aio/prompts/by_tags (body { tag_ids, page, ... })
  - delete: DELETE /aio/prompts       (body { ids })

Reconciled the spike's deferred call-site check against the actual consumer and
the spec:

- Remove the bogus POST from aio/prompts.js (keep DELETE → 204, the only op the
  spec defines on that path).
- Add aio/prompts/tagged.js → 201 IDsWithStatsResponse { ids, existing_count }.
- Add aio/prompts/by_tags.js → 200 AIOPromptsWithStatusListResponse
  { items, page, total, unassigned }; empty tag_ids lists all, else OR-filter.
  Created prompts carry a synthesized AIOTag so by_tags filtering is meaningful.
- Align the workspace-with-data prompt seed to AIOPromptWithStatus (name + tags,
  was the stale `text` field).
- Repoint the E2E at tagged/by_tags with a real write-then-read assertion plus a
  delete-reflects-in-list case.
- Update the README stateful-endpoints table.

Validated: npm test (51 passing, 100% coverage), npm run lint, npm run test:types,
npm run test:e2e (8 passing against the live mock with response validation on).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

Resolved — prompts stateful slice reconciled against the consumer and the spec (commit 4c510c1).

Follow-up to Should Fix #1 (the POST /aio/prompts handler). The prompts statefulness was reconciled against the actual consumer call sites (spacecat-api-servicesrc/support/serenity/rest-transport.js) and the spec, rather than the spike's guessed default, which had landed on a path the spec defines as delete-only.

What was wrong. The stateful create handler was mounted on POST /v2/.../aio/prompts, incorrect three ways:

  • Path — the spec defines only delete on /aio/prompts (aio-delete-prompt-by-ids-v2); there is no POST operation there.
  • Body — it read { items: [text] }; the real request is AIOTaggedPromptsCreateRequest{ prompts: { [tagName]: [text, ...] } }.
  • Status — it returned 200; the spec's create returns 201.

Because the spec declares no 200 response for that path, the { id, name } envelope was never response-validated — false coverage that looked like the floor was met.

The actual consumer flow (rest-transport.js):

  • create — POST /aio/prompts/tagged (aio-create-prompts-with-tags), body { prompts: { [tag]: [text] } }
  • list / read — POST /aio/prompts/by_tags (aio-list-prompts-by-tag-ids), body { tag_ids, page, limit, ... }
  • delete — DELETE /aio/prompts, body { ids }

handleListPrompts writes via tagged then reads via by_tags, depending on items[].{ id, name, tags } — a genuine write-then-read spine, so prompts does belong in the stateful set.

Fix (option a — wire the correct handlers, repoint the E2E):

  • Removed the bogus POST from aio/prompts.js; kept DELETE → 204 (the only op the spec defines on that path).
  • Added aio/prompts/tagged.js201 IDsWithStatsResponse { ids, existing_count }, wired to the in-memory store; created prompts carry a synthesized AIOTag so by_tags filtering is meaningful.
  • Added aio/prompts/by_tags.js200 AIOPromptsWithStatusListResponse { items, page, total, unassigned }; empty tag_ids lists all, otherwise OR-filters by tag id.
  • Realigned the workspace-with-data prompt seed to the real AIOPromptWithStatus shape (name + tags, replacing the stale text field) — also resolves the seed/created field mismatch (review nit chore: initial setup #2).
  • Repointed the E2E to drive tagged → by_tags with a real write-then-read assertion, plus a delete-reflects-in-list case.
  • Updated the README stateful-endpoints table.

Validation. The E2E now exercises the real spec operations with response validation on, so the 201/200 envelopes are confirmed spec-valid — the false-coverage gap is closed. All gates green: npm test (51 passing, 100% statements / branches / functions / lines), npm run lint, npm run test:types, npm run test:e2e (8 passing against the live mock).

Spec quirk worth flagging. On tagged, the workspace id is declared as a query parameter, though it is a path segment everywhere else and in the consumer's URL builder (aioPromptsPath). The handler reads $.path.id to match how the consumer actually calls it, and the E2E confirms it end-to-end. Noted because the generated types may model that parameter oddly.

The mock branch's package.json had `private` removed while carrying the
full go-public manifest (main/types/exports + publishConfig.access:public)
and was mergeable. `private: true` is the publish gate specifically — its
removal would trigger a first publish of this brand-new package on merge
to main, before its npm trusted-publisher binding exists.

Per the recorded sequencing, `private: true` exists to decouple PR merge
from publish; go-public is its own gated PR that lands only after the
binding. Re-add `private: true` (surgical — main/types/exports/publishConfig
left intact, as those are the real client surface and are wanted). Do not
rely on a rebase to restore this: the tip is go-public-shaped, so a 3-way
package.json merge would tend to carry that shape forward.

Metadata-only: lint, 51 unit tests @ 100% coverage, and test:types all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aliciadriani pushed a commit that referenced this pull request Jun 12, 2026
…atching deployed transport (LLMO-5461)

The wrapper authenticated by forwarding the raw IMS token as the `Auth-Data-Jwt`
header. Against the only reachable Project Engine endpoint — the Adobe-hosted
gateway — that 401s: the gateway authenticates `Authorization: Bearer <IMS>` and
injects Semrush's native `Auth-Data-Jwt` server-side. This replicates the proven
prod consumer, spacecat-api-service `src/support/serenity/rest-transport.js`,
whose `buildHeaders()` sends `Authorization: Bearer` and whose comment notes the
`Auth-Data-Jwt` branch was "deliberately removed".

- client.js: authMiddleware now sets `Authorization: Bearer <token>` (was
  `Auth-Data-Jwt`); still no exchange/minting — the gateway exchanges the bearer
  server-side.
- client.js: own the `/enterprise/projects/api` prefix like rest-transport —
  normalise baseUrl to its origin (drop any path/credentials) + the fixed prefix,
  and fail fast on an invalid baseUrl. Idempotent for callers that already include
  the prefix, so the unit tests and the #1665 mock e2e are unaffected.
- index.d.ts: keep the type-narrowing pointed at `Auth-Data-Jwt`. The generated
  `paths` still mark it required, but the gateway injects it server-side, so a
  consumer must not pass it; dropping the narrowing would force them to. The new
  `Authorization` header is middleware-injected, outside the typed params, so it
  needs no narrowing. Comments/JSDoc updated to the gateway-exchange model.
- tests: assert `Authorization: Bearer` (not `Auth-Data-Jwt`); add baseUrl
  normalisation + fail-fast coverage; reword the spec/title references.

Gates: lint clean, 29 unit passing @ 100% coverage, throwaway tsc confirms the
narrowing still holds (no-header call compiles; passing `Auth-Data-Jwt` is rejected).

Contract basis: confirmed by Rainer Friederich's first-hand live test
(`Authorization: Bearer` -> 200, `Auth-Data-Jwt` -> 401) plus the deployed
rest-transport.js. NOT independently re-probed — our IMS identity is not
provisioned on the Semrush side (the project's known no-live-access constraint),
so a local probe 401s on every header.

LLMO-5461

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rainer-friederich

rainer-friederich commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Heads-up for the rebase onto #1661 (now that it carries the generation-time overlay spec/overlays/corrections.yamlbuild/openapi3.json): src/mock/run.js needs two coupled changes, otherwise the mock will not reflect the corrected spec.

1. GET /v1/ai_models will 404 in the mock. run.js feeds Counterfact the pristine vendored swagger (SPEC = spec/projectengine_swagger_public.yaml), which by design does not contain /v1/ai_models — that path exists only in the overlaid build/openapi3.json. So "GET /v1/ai_models stays on Counterfacts auto-generated response" only holds if Counterfact is fed the overlaid artifact. → Point SPEC at build/openapi3.json, and ensure npm run spec:convert && npm run spec:overlay (i.e. npm run generate) has run before the mock boots (build/ is gitignored).

2. The route prefix breaks when you switch specs. Counterfact honors a Swagger-2.0 basePath as a serving prefix, but does not honor an OAS3 servers[0].url (verified empirically). The current raw-swagger setup gets /enterprise/projects/api for free from basePath; the converted/overlaid build/openapi3.json carries it only as servers[0].url, which Counterfact ignores → the real client (which appends /enterprise/projects/api) would 404. → Add --prefix /enterprise/projects/api to the Counterfact args. It applies to both the committed stateful handlers and the spec-derived routes, so the whole surface stays under the /enterprise/projects/api base path.

--no-validate-request already makes the Auth-Data-Jwt removal a no-op for the mock, so nothing is needed there.

Net diff to run.js:

const SPEC = join(packageRoot, 'build', 'openapi3.json'); // was spec/projectengine_swagger_public.yaml
// ...ensure build/openapi3.json exists first (npm run generate)...
[findCounterfactBin(), SPEC, BASE_PATH, '--port', String(PORT), '--serve',
 '--prefix', '/enterprise/projects/api', '--no-validate-request', '--no-update-check']

Alternative (bigger change): keep feeding Swagger 2.0 so basePath keeps prefixing (no --prefix needed), but apply the overlay to a Swagger-2.0 artifact so /v1/ai_models is present there. Since the overlay currently targets OAS3 post-conversion, feeding the overlaid build/openapi3.json + --prefix is the smaller change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants