|
| 1 | +# Semrush proxy — point at `adobe-hackathon.semrush.com` + project-creation endpoint |
| 2 | + |
| 3 | +**Status:** plan only (draft PR); no code in this PR yet |
| 4 | +**Base branch:** `feat/prompts-management` |
| 5 | +**Cross-repo design doc:** `adobe-rnd/llmo-data-retrieval-service` PR #1779 (`docs/design/serenity-adobe-com-semrush-integration-1-week-scope.md` §3.4) |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +The hackathon proxy under `/v2/orgs/:spaceCatId/brands/:brandId/serenity/*` (PR #2397 + #2385, currently on `feat/prompts-management`) hits `https://www.semrush.com` directly and uses a dual auth shim: `Auth-Data-Jwt` header for IMS pass-through, `SEMRUSH_COOKIE` fallback for dev. Semrush has since shipped a dedicated proxy host **`adobe-hackathon.semrush.com`** that accepts a plain `Authorization: Bearer <IMS-User-Token>` and handles IMS→Semrush user + workspace mapping server-side. That collapses our client-side auth: drop the `Auth-Data-Jwt` shim, drop the cookie fallback, point at the new host. |
| 12 | + |
| 13 | +Organization → Semrush workspace mapping is now on `mysticat-data-service` `main` (`organizations.semrush_workspace_id`, migration `20260525000000`, exposed via PostgREST). The proxy will resolve workspace per request from there instead of carrying it in the env matrix. |
| 14 | + |
| 15 | +Onboarding a new `(brand, market, language)` slice also needs a project-creation endpoint — the migration script (in `llmo-data-retrieval-service`) creates Semrush projects today via a manual cookie-auth path, but onboarding from the Elmo UI requires a proxied endpoint. |
| 16 | + |
| 17 | +## Scope |
| 18 | + |
| 19 | +### In scope |
| 20 | + |
| 21 | +Endpoints under `/v2/orgs/:spaceCatId/brands/:brandId/serenity/*`: |
| 22 | + |
| 23 | +| Method | Path | Status | What changes | |
| 24 | +|---|---|---|---| |
| 25 | +| GET | `/prompts` | existing | re-tune auth + workspace resolution | |
| 26 | +| POST | `/prompts` | existing | re-tune auth + workspace resolution | |
| 27 | +| PATCH | `/prompts/:promptId` | existing | re-tune auth + workspace resolution | |
| 28 | +| POST | `/prompts/bulk-delete` | existing | re-tune auth + workspace resolution | |
| 29 | +| GET | `/projects` | existing | re-tune auth + workspace resolution | |
| 30 | +| GET | `/projects/:workspaceId/:projectId/tags` | existing | re-tune auth | |
| 31 | +| POST | `/projects` | **new** | onboarding — create AIO project for a (brand, market, language) slice | |
| 32 | + |
| 33 | +Cross-cutting changes: |
| 34 | + |
| 35 | +- Transport base URL default: `https://adobe-hackathon.semrush.com` (replaces `https://www.semrush.com`) |
| 36 | +- Inbound auth header: `Authorization: Bearer <IMS>` forwarded as-is to Semrush (replaces `Auth-Data-Jwt`) |
| 37 | +- Remove `SEMRUSH_COOKIE` / `SEMRUSH_USER_AGENT` cookie-fallback branch |
| 38 | +- Workspace per request from `organizations.semrush_workspace_id` (5-min LRU cache) |
| 39 | +- Shrink `SEMRUSH_PROJECT_MATRIX` schema: `(brandId, category, market, language) → projectId` (workspaceId removed) |
| 40 | + |
| 41 | +### Out of scope (separate PRs) |
| 42 | + |
| 43 | +- Family-aware tag demux (`category:` / `topic:` / `intent:` / `branded:` / `source:`) — §3.3.1 |
| 44 | +- Site-cohort allow-list (`SERENITY_SITE_ALLOWLIST`) and `serenityEnabled` bit — §4.4 |
| 45 | +- Idempotency-Key + §6c error envelope alignment — §3.4 |
| 46 | +- `brand_to_semrush_projects` DB table on data-service (replaces env matrix later) — §3.1 |
| 47 | +- Reporting endpoints (`/serenity/reporting/*`) — different upstream auth (Apikey), left intact |
| 48 | +- AI Visibility gRPC routes on main (`/apis/serenity/v1/ai-visibility/*`) — untouched |
| 49 | +- Swagger-driven contract tests — §3.6 |
| 50 | + |
| 51 | +## Architecture — request flow |
| 52 | + |
| 53 | +``` |
| 54 | +Client (Elmo) |
| 55 | + │ Authorization: Bearer <IMS user token> |
| 56 | + │ e.g. GET /v2/orgs/{spaceCatId}/brands/{brandId}/serenity/prompts |
| 57 | + ▼ |
| 58 | +spacecat-api-service |
| 59 | + │ 1. inbound auth: validate IMS, attach ctx.imsUser (existing) |
| 60 | + │ 2. resolve workspaceId: (new) |
| 61 | + │ org = dataAccess.Organization.findById(spaceCatId) |
| 62 | + │ workspaceId = org.getSemrushWorkspaceId() |
| 63 | + │ 404 if null |
| 64 | + │ 3. resolve projectId(s) from SEMRUSH_PROJECT_MATRIX: (existing, refactored) |
| 65 | + │ matrix.listProjectsForBrand(brandId) → [{projectId, category, market, language}] |
| 66 | + │ (or matrix.resolveProject(brandId, {category, market, language}) for writes) |
| 67 | + │ 4. forward to adobe-hackathon.semrush.com (refactored) |
| 68 | + │ Authorization: Bearer {imsToken} |
| 69 | + ▼ |
| 70 | +adobe-hackathon.semrush.com |
| 71 | + │ exchanges IMS → Semrush token, maps user, forwards (server side) |
| 72 | + ▼ |
| 73 | +Semrush AIO |
| 74 | +``` |
| 75 | + |
| 76 | +**Two-tier resolution is intentional**: workspace mapping is durable (orgs onboard one-by-one over months), project mapping churns weekly as new markets / languages get added. Project mapping moves to a DB table later (out of scope). |
| 77 | + |
| 78 | +## Endpoint contracts |
| 79 | + |
| 80 | +All paths under `/v2/orgs/:spaceCatId/brands/:brandId/serenity`. All require `Authorization: Bearer <IMS>`. All return 404 if the org has no `semrush_workspace_id`. |
| 81 | + |
| 82 | +### `GET /prompts` |
| 83 | + |
| 84 | +- Query: `?page=1&limit=50&search=&category=&market=&language=` |
| 85 | +- Behaviour: fan-out across all projects in `matrix.listProjectsForBrand(brandId)`, merge results, paginate |
| 86 | +- Upstream: `POST /enterprise/projects/api/v2/workspaces/{ws}/projects/{pid}/aio/prompts/by_tags` per project |
| 87 | +- Response: `{ items: [{id, logicalId, text, tags: [...], market, language, category, ...}], total, page, limit }` |
| 88 | + |
| 89 | +### `POST /prompts` |
| 90 | + |
| 91 | +- Body: `{ prompts: [{text, tags: [], category, topic, intent, market, language}] }` |
| 92 | +- Behaviour: group by `(category, market, language)`, look up `projectId` per group, POST tagged prompts, publish each touched project |
| 93 | +- Upstream: `POST /v2/.../aio/prompts/tagged` + `POST /v1/.../publish` |
| 94 | +- Response: `{ created: [{logicalId, semrushId, projectId, ...}], failed: [...] }` |
| 95 | + |
| 96 | +### `PATCH /prompts/:promptId` |
| 97 | + |
| 98 | +- `:promptId` is the logical id (base64url JSON of `{brandId, category, language, text}`) |
| 99 | +- Body: `{ text?, tags?, ... }` |
| 100 | +- Behaviour: decode logical id → resolve project → DELETE old + POST new + publish (same as hackathon branch) |
| 101 | +- Response: `{ id: <new-logicalId>, semrushId, ... }` |
| 102 | + |
| 103 | +### `POST /prompts/bulk-delete` |
| 104 | + |
| 105 | +- Body: `{ semrushIds: [{projectId, promptId}, ...] }` |
| 106 | +- Behaviour: group by project, DELETE per project, publish per touched project |
| 107 | +- Upstream: `DELETE /v2/.../aio/prompts` |
| 108 | +- Response: `{ deleted: number, failed: [...] }` |
| 109 | + |
| 110 | +### `GET /projects` |
| 111 | + |
| 112 | +- Query: `?category=&market=&language=` |
| 113 | +- Behaviour: return matrix rows for the brand (workspace from DB, projects from matrix), enrich with live Semrush metadata via `listWorkspaceProjects` |
| 114 | +- Response: `{ items: [{projectId, workspaceId, name, category, market, language, status, ...}] }` |
| 115 | + |
| 116 | +### `GET /projects/:workspaceId/:projectId/tags` |
| 117 | + |
| 118 | +- Pass-through to Semrush |
| 119 | +- Upstream: `GET /v1/workspaces/{ws}/projects/{pid}/tags` |
| 120 | +- Response: `{ items: [{id, name}] }` (raw shape; tag demux is follow-up) |
| 121 | + |
| 122 | +### `POST /projects` (new — onboarding) |
| 123 | + |
| 124 | +- Body: |
| 125 | + ```json |
| 126 | + { |
| 127 | + "name": "adobe-com · AU · en", |
| 128 | + "category": "Creative Cloud", |
| 129 | + "market": "AU", |
| 130 | + "language": "en", |
| 131 | + "brandDomain": "adobe.com", |
| 132 | + "brandNames": ["Adobe", "Adobe.com"], |
| 133 | + "projectType": "aio" |
| 134 | + } |
| 135 | + ``` |
| 136 | +- Behaviour: |
| 137 | + 1. Resolve `workspaceId` from `org.getSemrushWorkspaceId()` (404 if not onboarded) |
| 138 | + 2. 409 conflict if matrix already has a row for `(brandId, category, market, language)` |
| 139 | + 3. Resolve `language_id` (Semrush UUID) by calling `GET /v1/languages` once on boot, cached 1h |
| 140 | + 4. Resolve `location_id` from a static `countryLocationMap` shipped in the repo (ISO 2-letter → Google geo target id) — same map the migration script uses |
| 141 | + 5. POST `POST /enterprise/projects/api/v1/workspaces/{ws}/projects` with `{name, type, brand_name_display, brand_names, domain, country_code, location_id, location_name, language_id}` |
| 142 | + 6. On success, publish (`POST /v1/.../publish`) |
| 143 | +- Response: `{ workspaceId, projectId, name, status: "draft" | "published" }` |
| 144 | +- Out of scope: writing a row to `brand_to_semrush_projects`. Matrix env JSON has to be updated manually after onboarding (acceptable — onboarding is rare, 19 projects total for adobe.com). |
| 145 | + |
| 146 | +## Files to modify |
| 147 | + |
| 148 | +| File | Change | Why | |
| 149 | +|---|---|---| |
| 150 | +| `src/support/serenity/rest-transport.js` | Drop `SEMRUSH_COOKIE` / `SEMRUSH_USER_AGENT` branch from `buildHeaders`. Switch IMS header from `Auth-Data-Jwt` to `Authorization: Bearer <token>`. Change `DEFAULT_BASE_URL` to `https://adobe-hackathon.semrush.com`. Add `createProject(workspaceId, body)`. Keep existing `publishProject`. | Core auth + base URL refactor + new endpoint | |
| 151 | +| `src/support/serenity/matrix.js` | Remove `workspaceId` from matrix schema. `listProjectsForBrand` and `resolveProject` return rows without workspaceId. | Workspace now comes from DB; matrix is project-only | |
| 152 | +| `src/support/serenity/workspace-resolver.js` | **New file.** Export `resolveWorkspaceId(ctx, spaceCatId)` — reads `ctx.dataAccess.Organization.findById(spaceCatId)`, returns `semrush_workspace_id` or null. In-memory 5-min LRU cache keyed by spaceCatId. | Centralised workspace lookup | |
| 153 | +| `src/support/serenity/handlers/prompts.js` | `handleListPrompts` / `handleCreatePrompts` / `handleUpdatePrompt` / `handleBulkDeletePrompts` take `workspaceId` as an arg instead of pulling it from matrix rows. | Two-tier resolution | |
| 154 | +| `src/support/serenity/handlers/projects.js` | **New file.** Export `handleCreateProject(transport, env, {workspaceId, body, log})`. Looks up language UUID + location_id, calls `transport.createProject`, then `transport.publishProject`. | Onboarding logic | |
| 155 | +| `src/support/serenity/data/locations.json` | **New file.** ISO 2-letter → `{location_id, location_name}` map. Copy from migration script's `COUNTRY_LOCATION_BY_CODE`. | Static data for project creation | |
| 156 | +| `src/controllers/serenity-prompts.js` | Each controller method: call `resolveWorkspaceId(ctx, spaceCatId)`, 404 if null, pass workspaceId into the handler. | Wire workspace resolution | |
| 157 | +| `src/controllers/serenity-projects.js` | **New file (or extend serenity-prompts.js).** `createProject(ctx)` handler. | New endpoint | |
| 158 | +| `src/routes/index.js` | Add `POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/projects`. | New endpoint | |
| 159 | +| `test/support/serenity/rest-transport.test.js` | Update header assertions, base URL, add `createProject` tests. | Test surface follows code | |
| 160 | +| `test/support/serenity/matrix.test.js` | Update matrix schema (no workspaceId). | Schema change | |
| 161 | +| `test/support/serenity/workspace-resolver.test.js` | **New.** Test the cache + the data-access call. | New module | |
| 162 | +| `test/support/serenity/handlers/prompts.test.js` | Pass workspaceId explicitly to handlers. | API shift | |
| 163 | +| `test/support/serenity/handlers/projects.test.js` | **New.** Test handleCreateProject with mocked transport + language cache. | New module | |
| 164 | +| `test/controllers/serenity-prompts.test.js` | Stub `dataAccess.Organization.findById`. Add 404 case (org has no semrush_workspace_id). | New auth path | |
| 165 | +| `test/controllers/serenity-projects.test.js` | **New.** POST `/projects` flow with mocked transport + dataAccess. | New endpoint | |
| 166 | +| `docs/openapi/*` | Update Semrush proxy endpoint specs to reflect new auth + add the `/projects` POST. | OpenAPI-first per repo conventions | |
| 167 | +| `README.md` (or new `docs/serenity.md`) | Document env vars + the new auth model + the onboarding endpoint. | Operator-facing | |
| 168 | + |
| 169 | +## Dependencies |
| 170 | + |
| 171 | +1. **`@adobe/spacecat-shared-data-access`** must expose `getSemrushWorkspaceId()` on the `Organization` entity. The column exists in DB + PostgREST; if the shared lib's entity model doesn't surface it yet: |
| 172 | + - **Preferred:** add the getter in `adobe/spacecat-shared` (small follow-up PR) |
| 173 | + - **Interim:** fall back to `ctx.dataAccess.services.postgrestClient` raw query in `workspace-resolver.js` |
| 174 | +2. **No new env vars required** — `SEMRUSH_PROJECTS_BASE_URL` already exists; we just change its default. The matrix env JSON shape changes (workspaceId removed) — deployment must update Vault / Helix env values before this lands in dev / prod. |
| 175 | + |
| 176 | +## Implementation order |
| 177 | + |
| 178 | +1. Verify `@adobe/spacecat-shared-data-access` exposes `Organization.getSemrushWorkspaceId()`; file the follow-up or wire raw PostgREST. |
| 179 | +2. Refactor `rest-transport.js`: header + base URL + drop cookie branch. |
| 180 | +3. Add `workspace-resolver.js` + tests. |
| 181 | +4. Refactor `matrix.js`: drop workspaceId from schema + tests. |
| 182 | +5. Refactor existing handlers + controllers to wire workspace resolution. |
| 183 | +6. Update existing tests for new header shape + matrix shape. |
| 184 | +7. Add `createProject` to transport + `handlers/projects.js` + `controllers/serenity-projects.js`. |
| 185 | +8. Add the new route + tests. |
| 186 | +9. Update OpenAPI specs + `README.md` / `docs/serenity.md`. |
| 187 | +10. Mark PR ready-for-review. |
| 188 | + |
| 189 | +## Verification |
| 190 | + |
| 191 | +End-to-end (post-deploy to dev): |
| 192 | + |
| 193 | +- `mysticat auth token --env dev` → grab IMS token |
| 194 | +- `curl -H "Authorization: Bearer $TOKEN" 'https://spacecat.experiencecloud.live/api/ci/v2/orgs/<spaceCatId>/brands/<brandId>/serenity/prompts?limit=5'` → expect `200` with list shape |
| 195 | +- `curl -X POST -H "Authorization: Bearer $TOKEN" -d '{"prompts":[{"text":"test","tags":[],"market":"AU","language":"en"}]}' '.../serenity/prompts'` → expect `200` with `semrushId` |
| 196 | +- Non-onboarded org (no `semrush_workspace_id`) → expect `404` |
| 197 | +- `curl -X POST -H "Authorization: Bearer $TOKEN" -d '{"name":"...","market":"AU","language":"en","category":"Creative Cloud","brandDomain":"adobe.com"}' '.../serenity/projects'` → expect `201` with `{workspaceId, projectId, status:"published"}` |
| 198 | + |
| 199 | +Unit / integration: |
| 200 | + |
| 201 | +- `npm test -- --grep 'serenity'` — all renamed / added suites pass |
| 202 | +- `nock` recorder confirms outbound `Authorization: Bearer ...` (not `Auth-Data-Jwt`) and `adobe-hackathon.semrush.com` host |
| 203 | + |
| 204 | +## Open questions |
| 205 | + |
| 206 | +- Does `adobe-hackathon.semrush.com` honour the same paths as `www.semrush.com/enterprise/projects/api/v1|v2/...`? Verify with one curl before merging the code PR. If paths differ, env-driven path prefix is trivial. |
| 207 | +- Does the `Organization` entity expose `getSemrushWorkspaceId()` in `@adobe/spacecat-shared-data-access` already? (see Dependencies §1) |
| 208 | +- Should the matrix env JSON also be removed entirely and replaced with a per-request DB lookup against `brand_to_semrush_projects`? Out of scope here but worth a tracking ticket once the table exists. |
| 209 | + |
| 210 | +## Follow-ups (file as Jira tickets after this PR) |
| 211 | + |
| 212 | +- Family-aware tag demux (§3.3.1) |
| 213 | +- Site-cohort allow-list + `serenityEnabled` bit (§4.4) |
| 214 | +- Idempotency-Key + §6c error envelope |
| 215 | +- `brand_to_semrush_projects` DB table on `mysticat-data-service` |
| 216 | +- `Organization.getSemrushWorkspaceId()` on `@adobe/spacecat-shared-data-access` (if not already there) |
| 217 | +- Swagger-driven contract tests against Semrush spec (§3.6) |
0 commit comments