# AdCenter level-1 keys + synthetic fixtures (TW-301)

**Audience:** AdCenter ops, Platform, Infra/Security  
**Related:** [TW-301](https://admedia-jira.atlassian.net/browse/TW-301) · [TW-295](https://admedia-jira.atlassian.net/browse/TW-295) · [BUSINESS-METRICS-CREDENTIALS.md](./BUSINESS-METRICS-CREDENTIALS.md)  
**Fixture registry (IDs only):** [fixtures/adcenter-staging.json](./fixtures/adcenter-staging.json)  
**MCP consumer notes:** `business-mcp.lucos.com/docs/ADCENTER-LEVEL1.md`

---

## Goal

Provision **dedicated** AdCenter **level-1** `X-API-KEY`s and synthetic advertiser fixtures so Lucos connectors can call AdCenter v1 safely in staging.

**Primary path:** gateway **ensure-key** — process cache, then mint level-1 on miss via `PUT /api/key`. ChatGPT never mints and never sees keys. Staging and prod mint are gated by `ADCENTER_ENSURE_KEY_ENABLED` + `ADCENTER_ADMIN_API_KEY`.

---

## Hosts / env separation

| Lucos env | AdCenter API base (env var) | Default in `.env.example` |
| --- | --- | --- |
| staging | `ADCENTER_API_BASE_URL_STAGING` | `https://apiad.admedia.com/v1/` |
| prod | `ADCENTER_API_BASE_URL_PROD` | `https://apiad.admedia.com/v1/` |

**AdCenter application hosts (from `advertisers7_new` `environment.php`):**

| Host | Role |
| --- | --- |
| `https://apiad.admedia.com/v1/` | Production AdCenter REST (`API_URI` when `DEV_MODE` is false) — **Lucos connector target** |
| `dev.apiad.admedia.com` | Recognized AdCenter API host (confirm with SysAdmin/AdOps before Lucos use) |
| `https://stagingadcenter.admedia.com` | AdCenter **UI** login host only — **not** a REST base (`/v1/*` is not served here) |
| Local / `DEV_MODE` | Developer workspace API URI (not for Lucos staging vault) |

**Locked for TW-301 close-out:** Lucos *staging* calls prod `apiad.admedia.com` against synthetic advertiser **19880** only (no production advertiser PII fixtures). Do not point Lucos staging at `dev.apiad` unless AdOps explicitly revises this.

Key mint endpoint (not under `/v1/`):

```text
PUT https://apiad.admedia.com/api/key
```

(`User_model::generateApiKey` and `controllers/api/Key.php` — mint requires **admin level 10**.)

---

## Primary path: gateway ensure-key

MCP calls `POST /api/v1/tools/adcenter_ensure_level1_key` after **Bearer + RBAC**. The route is **not** an MCP-registered tool. Response shape: `{ advertiser_id, environment, x_api_key }`. ChatGPT never sees that body — MCP uses `x_api_key` as AdCenter `X-API-KEY` over TLS.

On the **gateway** (`api.lucos.com`) only:

```bash
ADCENTER_ENSURE_KEY_ENABLED=true
# ADCENTER_ENSURE_KEY_PROD is ignored (prod mint uses ENABLED + admin key, same as staging)
ADCENTER_ADMIN_API_KEY=<admin-level-10-key>
ADCENTER_KEY_MINT_BASE_URL=https://apiad.admedia.com
ADCENTER_LEVEL1_KEYS_STAGING={}
ADCENTER_LEVEL1_KEYS_PROD={}
```

Empty `ADCENTER_LEVEL1_KEYS_*={}` is OK when ensure-key is on. Resolution: **process cache → optional env map → mint level-1 on miss**.

**Never** put `ADCENTER_ADMIN_API_KEY` on `business-mcp.lucos.com`. Never return the admin key. MCP receives level-1 `x_api_key` over TLS (not ChatGPT-facing).

**Prod mint** uses the same gate as staging: `ADCENTER_ENSURE_KEY_ENABLED=true` and `ADCENTER_ADMIN_API_KEY`. `ADCENTER_ENSURE_KEY_PROD` is ignored.

**Logs:** fingerprint only (`sha256` hex prefix, 12 chars). Never log the admin key, minted key, or `x_api_key`.

Service: `src/services/adcenter-ensure-key.service.js`.

---

## Ownership

| Step | Owner |
| --- | --- |
| Create/confirm synthetic advertiser + campaign/creative/report data | AdCenter ops |
| Enable gateway ensure-key + inject `ADCENTER_ADMIN_API_KEY` on `api.lucos.com` | Platform / Infra (vault / host env) |
| Mint dedicated Lucos **level-1** keys (`level: 1` only) | Gateway ensure-key (admin / level-10). Offline script is **break-glass / migration** |
| Empty `ADCENTER_LEVEL1_KEYS_*={}` on MCP + gateway when ensure-key is on | Platform / Infra |
| Never use UI-generated keys (they are **level 2**) | Everyone |
| Prod key mint | Same as staging (`ADCENTER_ENSURE_KEY_ENABLED` + `ADCENTER_ADMIN_API_KEY`; `ADCENTER_ENSURE_KEY_PROD` is ignored) |

---

## Advertiser → key mapping (legacy / break-glass)

AdCenter does **not** accept `advertiser_id` as a query parameter. After tool RBAC allow, Lucos selects or mints the key.

When ensure-key is on, leave maps empty:

```json
ADCENTER_LEVEL1_KEYS_STAGING={}
```

The JSON map is **break-glass / migration** only (pin a known key without minting):

```json
ADCENTER_LEVEL1_KEYS_STAGING={"19880":"<level-1-key>"}
```

Resolved by `getAdCenterLevel1Key` in `src/services/tool-credentials.service.js` (env map) or `ensureAdCenterLevel1Key` in `src/services/adcenter-ensure-key.service.js` (cache → map → mint).

---

## Staging smoke (ChatGPT / MCP)

1. Gateway: `ADCENTER_ENSURE_KEY_ENABLED=true` + `ADCENTER_ADMIN_API_KEY` set. MCP: `ADCENTER_LEVEL1_KEYS_STAGING={}` (empty).
2. Name-first: `search_advertisers` `q=lucos_synth_adv` (or `get_advertiser` `adv_id` **19880**).
3. `adcenter_list_campaigns` with that `advertiser_id`.
4. Confirm campaigns return and **no** keys appear in MCP / ChatGPT output.

**Negative:** disable ensure-key (`ADCENTER_ENSURE_KEY_ENABLED` off) or unset `ADCENTER_ADMIN_API_KEY` → fail closed (`ADCENTER_ENSURE_DISABLED` / mint fail). No key in MCP output.

---

## AdOps request package (when engineering cannot mint)

Ask AdCenter ops to:

1. Confirm synthetic advertiser **19880** (or provision a Lucos-only synthetic advertiser).
2. Ensure it has at least one campaign, one creative, and a known reportable date range (synthetic traffic only; no production PII).
3. Mint a **new dedicated** key (break-glass; prefer gateway ensure-key):

```http
PUT /api/key
Content-Type: application/json
X-API-KEY: <admin-level-10-key>

{"adv_id": 19880, "level": 1}
```

4. Return the key **out of band** (secret manager / 1:1 secure channel — **not** Jira/Confluence/chat). Prefer injecting as gateway `ADCENTER_ADMIN_API_KEY` (mint on demand) rather than pasting level-1 keys into MCP JSON maps.
5. Return non-secret IDs for the private fixture registry: `advertiser_id`, `campaign_id`, `creative_id`, report `from`/`to`.

**Hard rules for the request:**

- `level` must be **1** (read). Do not mint level 2 for Lucos.
- Do not reuse UI “Generate API key” (issues level 2).
- Do not enable Basic + `X-API-ADVID` for Lucos.

---

## Mint locally (break-glass / migration — not the ChatGPT path)

Operators with an admin key can still mint offline for vault injection or debugging. This is **not** how ChatGPT gets a key.

```bash
# Never commit the admin or level-1 keys.
export ADCENTER_KEY_MINT_BASE_URL=https://apiad.admedia.com
export ADCENTER_ADMIN_API_KEY='<admin-level-10-key>'
export ADCENTER_ADV_ID=19880

./scripts/mint-adcenter-level1-key.sh
```

The script prints only a success/failure status and a **redacted** key fingerprint. Capture the raw key from the script’s optional `ADCENTER_MINT_PRINT_KEY=1` mode only on a secure operator workstation, then inject into vault/env immediately.

Do **not** document “mint offline and paste into MCP JSON map” as the ChatGPT path.

---

## Inject into Lucos (never git)

1. On the Lucos API **staging** host, set ensure-key on and leave maps empty when mint-on-miss is desired:

```bash
ADCENTER_ENSURE_KEY_ENABLED=true
# ADCENTER_ENSURE_KEY_PROD is ignored (prod mint uses ENABLED + admin key, same as staging)
ADCENTER_ADMIN_API_KEY=<admin-level-10-key>
ADCENTER_LEVEL1_KEYS_STAGING={}
ADCENTER_LEVEL1_KEYS_PROD={}
ADCENTER_API_BASE_URL_STAGING=https://apiad.admedia.com/v1/
```

2. On **business-mcp**, leave `ADCENTER_LEVEL1_KEYS_STAGING={}` (or unset). Never set `ADCENTER_ADMIN_API_KEY` there.
3. Restart the gateway (`pm2 restart` / deploy process).
4. Confirm fail-closed lookup when ensure-key is off or admin key is missing (unit tests cover this).

---

## Discover fixture IDs after mint

```bash
export ADCENTER_API_BASE_URL=https://apiad.admedia.com/v1
export ADCENTER_LEVEL1_API_KEY='<level-1-key>'
export ADCENTER_CAMPAIGN_ID='<optional>'
export ADCENTER_CREATIVE_ID='<optional>'

./scripts/smoke-adcenter-level1.sh
```

Update [fixtures/adcenter-staging.json](./fixtures/adcenter-staging.json) `sample_ids` with discovered **IDs only**.

---

## Rotation / revoke

1. Mint a replacement level-1 key (`PUT /api/key` with `level: 1`) — gateway ensure-key on cache miss, or the offline script.
2. If using the env map, update `ADCENTER_LEVEL1_KEYS_STAGING` on the staging host; otherwise restart `api.lucos.com` to clear the process cache (or wait for a new advertiser miss).
3. Restart `api.lucos.com`.
4. Revoke the old key:

```http
DELETE /api/key?key=<old-key>
X-API-KEY: <admin-level-10-key>
```

5. Keep staging and prod rotations independent.

---

## Level-10 rejection (Lucos)

Lucos connectors must use only:

- `buildAdCenterAuthHeaders(level1Key)` → `{ 'X-API-KEY': ... }`
- `assertSafeAdCenterAuth(headers)` before outbound calls

Automated evidence: `npm test -- tests/unit/tool-credentials.service.test.js`  
(asserts Basic / `X-API-ADVID` / `X-API-LEVEL >= 2` are rejected).

---

## Acceptance checklist (TW-301)

- [ ] Gateway ensure-key on for staging (`ADCENTER_ENSURE_KEY_ENABLED=true`) + `ADCENTER_ADMIN_API_KEY` in gateway vault/env — not git, **not** business-mcp
- [ ] MCP `ADCENTER_LEVEL1_KEYS_STAGING={}` (empty OK) — not a hardcoded ChatGPT map
- [ ] Staging smoke: `search_advertisers` `lucos_synth_adv` / id **19880** → `adcenter_list_campaigns`
- [ ] Negative: ensure-key off or admin key unset → fail closed; **no** key in MCP output
- [ ] Logs fingerprint only (sha256-12); never admin key / minted key / `x_api_key`
- [ ] Synthetic advertiser + sample campaign/creative IDs in [fixtures/adcenter-staging.json](./fixtures/adcenter-staging.json)
- [ ] Level-10 path rejected for Lucos (unit tests + this runbook)
- [ ] Prod mint uses the same gate as staging (`ADCENTER_ENSURE_KEY_ENABLED` + admin key; `ADCENTER_ENSURE_KEY_PROD` ignored)
- [ ] Offline mint script kept as break-glass / migration only — not the ChatGPT path
