Skip to content

Commit ab1ea9b

Browse files
docs(serenity): plan for adobe-hackathon.semrush.com proxy + project-creation endpoint
Plan-only PR. Documents the cutover from www.semrush.com to the new adobe-hackathon.semrush.com proxy host (which handles IMS→Semrush token exchange + user/workspace mapping server-side), per-request workspace resolution from organizations.semrush_workspace_id on mysticat-data-service, and the new POST /serenity/projects onboarding endpoint for (brand, market, language) slices. Code lands in follow-up commits on this branch. Cross-repo design: adobe-rnd/llmo-data-retrieval-service#1779 §3.4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9e08d3c commit ab1ea9b

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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

Comments
 (0)