feat(serenity): add market detail endpoint exposing semrushProjectId (LLMO-5190 follow-up)#2519
feat(serenity): add market detail endpoint exposing semrushProjectId (LLMO-5190 follow-up)#2519rainer-friederich wants to merge 5 commits into
Conversation
…(LLMO-5190 follow-up)
LLMO-5190 (PR 2506) hid Semrush as an implementation detail and dropped
semrushProjectId from the public /serenity/* DTOs. The embedded Semrush AIO
renderer MFE needs the resolved upstream project id to mount the dashboard for
the selected market — a use case the abstraction did not anticipate.
Add a single, explicit detail endpoint rather than re-adding the field to the
heavily-used list (Option B):
GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/markets/:geoTargetId/:languageCode
-> 200 SerenityMarketDetail (SerenityMarket + semrushProjectId)
-> 404 marketNotFound when the slice has no row
-> 400 on a malformed slice (strict /^\d+$/ geo guard, as deleteMarket)
IMS-only, organization:read
This keeps the /markets list provider-free and contains the one unavoidable
Semrush leak (embedding the provider's own widget genuinely needs the
provider's id) to one named, deprecable route. The field keeps the honest
semrushProjectId name (same rule as semrushPromptId).
The re-exposure is a deliberate, scoped deviation from the abstraction spec,
documented with rationale in docs/specs/2026-05-28-prompts-api-abstraction.md
section 11 and the decision doc docs/specs/2026-05-29-serenity-market-detail-endpoint.md.
Tests: handler unit, controller, OpenAPI contract (operationId<->fixture parity
+ SerenityMarketDetail conformance), and IT auth-gate. Lint, docs:lint,
docs:build, and all three mocha suites pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The getRouteHandlers "segregates static and dynamic routes" test asserts the exact set of dynamic routes via have.same.members. Adding the new GET market detail route made the actual set one larger than the snapshot. Register the route in the expected list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
There was a problem hiding this comment.
Hey @rainer-friederich,
Strengths
- Deliberate abstraction containment - the decision to expose the provider id at a single, named detail endpoint (Option B) rather than re-polluting the list is architecturally sound. It preserves the LLMO-5190 investment, contains the leak to one deprecable route, and documents the deviation with rationale. This is how you handle an unavoidable abstraction breach.
- Reuse of existing path object (
v2-serenity-market-by-slice) - addingget:alongside the existingdelete:avoids path proliferation and keeps the URL space resource-oriented. - Pure DB read with no transport dependency (
src/support/serenity/handlers/markets.js:157-209) - the handler takes onlydataAccessand the slice coordinates. No upstream failure modes, no rate limiting concerns, near-zero operational cost. - Brand-scoped authorization prevents IDOR (
src/controllers/serenity.js:339) -brandIdcomes fromauth.brandUuid(derived from the validated IMS token), not from a user-controlled path parameter. Callers cannot enumerate another org's market slices. - Strict input validation with safe regex (
src/controllers/serenity.js:341) -/^\d+$/for geoTargetId and the BCP-47 pattern for languageCode are both anchored, bounded, and free of catastrophic backtracking. - Spec-first with documented deviation - recording the deviation in the parent spec (section 11) with a pointer to the full analysis doc is exactly the right pattern for future discoverability.
- Solid test coverage across four layers - handler unit tests (validation + happy path), controller tests (forwarding + auth gate), OpenAPI contract test (schema conformance), and IT auth-gate test provide defense in depth.
Issues
Important (Should Fix)
OpenAPI spec declares 502 but the handler cannot produce one
docs/openapi/serenity-api.yaml (the get: operation on v2-serenity-market-by-slice): The response table includes a 502 "Upstream returned a non-2xx response." However, handleGetMarket makes no upstream HTTP call - it is a pure DB read via findBySlice. The only error paths are 400 (validation) and 404 (no row).
A declared 502 that can never fire misleads consumers into writing retry/fallback logic for a scenario that cannot occur. It also signals "this endpoint talks to an upstream" which is architecturally false and could confuse future maintainers about the endpoint's dependencies.
Fix: Remove the '502' response entry from the get: operation. The delete: operation on the same path legitimately has a 502 (it calls upstream Semrush), so the discrepancy would actually be clarifying.
Minor (Nice to Have)
Handler validation tests don't assert specific status codes
test/support/serenity/handlers/markets.test.js - the 400-path tests use .to.be.rejectedWith(ErrorWithStatusCode) but do not assert .status === 400. If normalizeGeoTargetId were ever to throw a different status, these tests would still pass. The 404 test already follows the stronger pattern (catching and asserting .status and .code).
Fix: Add .and.eventually.have.property('status', 400) or catch-and-assert like the 404 test does.
Recommendations
-
Consider cacheability signals. This endpoint returns stable data (the Semrush project id for a market slice rarely changes). The MFE calls it every time a user selects a market. Adding a
Cache-Controlresponse header or documenting expected caching behavior would reduce per-selection round trips and make Option B's extra request effectively free for repeat visits. -
DTO extraction if the response grows. The handler returns a plain object literal rather than going through a DTO class. This is consistent with the existing serenity domain pattern (no
src/dto/serenity*.jsexists), and the spirit of the DTO rule is satisfied (fields are explicitly picked via getters, not raw model exposure). If the detail response gains more fields over time, extracting a formal DTO would keep the serialization contract testable in isolation.
Assessment
Ready to merge? With fixes
Reasoning: Well-structured, follows existing patterns closely, solid test coverage, and the security posture is clean. The one Important finding (phantom 502 in the OpenAPI spec) is a contract mismatch that misleads consumers but does not affect runtime behavior. A one-line removal resolves it.
Next Steps
- Remove the
502response from theget:operation indocs/openapi/serenity-api.yaml. - Optionally tighten the handler-level 400 test assertions.
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 49s | Cost: $3.58 | Commit: bb47a6593603ddf4d10c0e1ced042926376e49ef
If this code review was useful, please react with 👍. Otherwise, react with 👎.
…rt 400 status in handler tests Addresses MysticatBot-Dev review on PR 2519. - Important: getSerenityMarket's OpenAPI response table declared a 502, but handleGetMarket is a pure DB read (findBySlice) with no upstream call — 502 can never fire. Removed it (with a comment noting the sibling delete: keeps its legitimate 502). Only 400/404/500 remain. - Minor: the two handleGetMarket 400-path tests now catch-and-assert status === 400 (matching the 404 test's stronger pattern) instead of only asserting the error type. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @MysticatBot-Dev — addressed in Important — phantom 502 in Minor — 400-path test assertions: Both On the two recommendations:
Re-requesting review. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Hey @rainer-friederich,
Re-review after commit a022f4b - both prior findings are addressed. Clean approval.
Strengths
- Previously flagged issues resolved cleanly. The phantom 502 is removed from the OpenAPI spec with a clarifying inline comment explaining why the sibling
delete:keeps its 502 (docs/openapi/serenity-api.yaml:358-360). The handler-level 400 tests now use catch-and-assert with explicitexpect(err.status).to.equal(400)(test/support/serenity/handlers/markets.test.js:694-700), matching the stronger 404 test pattern. - Deliberate abstraction containment - the decision to expose the provider id at exactly one named detail endpoint (Option B) rather than re-polluting the list preserves the LLMO-5190 investment, contains the leak to one deprecable route, and documents the deviation with rationale in section 11 of the parent spec.
- Pure DB read with no transport dependency (
src/support/serenity/handlers/markets.js:157-209) - the handler takes onlydataAccessand the slice coordinates. No upstream failure modes, no rate limiting concerns, near-zero operational cost. - IDOR prevention via auth-derived brandId (
src/controllers/serenity.js:339) -brandIdcomes fromauth.brandUuid(derived from the validated IMS token), not from a user-controlled path parameter. Callers cannot enumerate another org's market slices. - Strict input validation with safe regex (
src/controllers/serenity.js:341) -/^\d+$/for geoTargetId and the BCP-47 pattern for languageCode are both anchored, bounded, and free of catastrophic backtracking. - Consistent controller shape -
getMarketfollows the exact same structure as the adjacentdeleteMarket: IMS gate, authorize, strict-digit geo guard, delegate to handler, wrap inok(), catch withmapError. A new team member scanning this file will immediately understand the pattern. - Schema composition via allOf (
docs/openapi/schemas.yaml) -SerenityMarketDetailextendsSerenityMarketrather than duplicating fields. WhenSerenityMarketgrows a field, the detail response picks it up automatically. - Four-layer test coverage - handler unit (validation + happy path + 404), controller (forwarding + auth gate + error mapping), OpenAPI contract (schema conformance), and IT (auth-gate parity with DELETE). Each layer tests a distinct concern.
- Conventions fully followed - OpenAPI-first, schemas in
schemas.yaml, route insrc/routes/index.js, capability inrequired-capabilities.js, existing patterns reused. All documented CLAUDE.md rules for adding a new endpoint are satisfied.
Issues
Minor (Nice to Have)
createdAt/updatedAt in response - verify schema coverage - src/support/serenity/handlers/markets.js:196-197 returns createdAt and updatedAt, and the OpenAPI contract test validates the response against SerenityMarketDetail. These fields presumably come through the allOf reference to SerenityMarket. If SerenityMarket already declares them, no action needed. If it does not, the contract test might pass only because AJV allows additional properties by default, and the schema would not document what the endpoint actually returns.
Fix: Verify that SerenityMarket declares createdAt and updatedAt. If not, add them to the SerenityMarketDetail schema alongside semrushProjectId.
Assessment
Ready to merge? Yes
Reasoning: Well-structured, follows existing patterns closely, solid test coverage across four layers, security posture is clean, prior findings addressed, and all project conventions satisfied. The one Minor item is a schema-completeness check that is likely already satisfied by SerenityMarket's definition.
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 0m 36s | Cost: $2.50 | Commit: a022f4b999ef3a439f7aea11ffb9e93c5d68a472
If this code review was useful, please react with 👍. Otherwise, react with 👎.
codecov/patch was just under target: getMarket's `pLang ? ...toLowerCase() : null` guard had its `: null` side uncovered (deleteMarket tests the empty-segment path, getMarket did not). Add the empty-languageCode controller test so both sides of the slice-param guards are exercised. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the re-review and approval @MysticatBot-Dev. On the Minor verify item ( Also addressed the codecov/patch gap in a follow-up commit: |
codecov/patch flagged 2 missing lines (src/controllers/serenity.js:339-340) — getMarket's `if (auth.error) return auth.error;` block was never exercised because no getMarket test triggered an authorize() failure. Add a test where access control denies (hasAccess -> false), asserting the 403 is returned and the handler is not dispatched. getMarket is now fully covered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Related Issues
Follow-up to LLMO-5190 (PR 2506, "hide Semrush as an implementation detail"). Epic LLMO-5005. No new ticket filed yet.
Problem
LLMO-5190 dropped
semrushProjectIdfrom the public/serenity/*DTOs and now resolves the upstream project server-side from the(brandId, geoTargetId, languageCode)slice. That removed the value a UI consumer used to read offserenityProjectsApi.projects[].semrushProjectId.The embedded Semrush AIO renderer MFE needs the resolved upstream project id to mount the dashboard for the selected market — a use case the abstraction did not anticipate (it assumed the Prompts Management page only needs list/CRUD, not a Semrush-rendered dashboard). With the new surface there is no way for the client to obtain the id.
Approach — Option B (dedicated detail endpoint), not a field on the list
Two options were weighed:
semrushProjectIdto the/serenity/marketslist item. Minimal, no extra round trip, but reverses LLMO-5190 on the most-used surface and invites every consumer to couple to the id.B wins long-run: it preserves the abstraction the team just built and isolates the leak. Reuses the existing
v2-serenity-market-by-slicepath object (already had the:geoTargetId/:languageCodeparams andDELETE). The field keeps the honestsemrushProjectIdname — the value genuinely is Semrush's project UUID (same rule LLMO-5190 applied tosemrushPromptId).Deviation from the abstraction spec — documented
LLMO-5190 states
semrushProjectIdis a routing detail to drop from public DTOs. Re-exposing it is a deliberate, scoped deviation, recorded with rationale in:docs/specs/2026-05-28-prompts-api-abstraction.mdsection 11docs/specs/2026-05-29-serenity-market-detail-endpoint.md(full analysis, contract, validation gates)Checklist
get:operation (operationId: getSerenityMarket) onv2-serenity-market-by-slice; newSerenityMarketDetailschema (allOfofSerenityMarket+semrushProjectId)./serenity/*surface is schema-only (noexamples.yamlentries); this endpoint follows that established convention. The OpenAPI contract test AJV-validates the200body againstSerenityMarketDetailinstead.Tests & validation (all green locally)
test/support/serenity/handlers/markets.test.js—handleGetMarket(400 invalid geo, 400 malformed lang, 404 marketNotFound, happy-path detail).test/controllers/serenity.test.js—getMarket(forwarding + ok(), strict-digit null-route, 404 token mapping, IMS-only 401).test/openapi-contract/serenity-api.test.js— operationId↔fixture parity gate +getSerenityMarketbody conforms toSerenityMarketDetail.test/it/shared/tests/serenity.js— GET market-detail IMS-only auth gate (mirrors the DELETE gate).lint,docs:lint,docs:build, handler/controller/contract mocha suites. Thebuild(Lambda bundle) gate runs in CI — locally it stops at deploy-parameter resolution (${env.VPC_SUBNET_1}), an env gap, not a code issue; this change touches no bundle-layer concerns (no new assets, FS-at-boot reads,hlx.static, or top-level side-effects).Follow-up (separate PR, separate repo)
project-elmo-ui: add a
getSerenityMarket(orgId, brandId, geoTargetId, languageCode)client + hook and feedsemrushProjectIdto the renderer MFE. Lives on the elmo serenity feature branch (PR 1815), not onmain.🤖 Generated with Claude Code