# Demo Studio API

The Node server serves the editable product, Studio, source-backed mode, and JSON API. This is a locally tested foundation, not a production SaaS qualification. Runtime: Node 22.13+ with `node:sqlite` available (experimental in tested Node 22.22.3). Start with `node server.mjs` from `clickthrough/`.

## Authentication and workspaces

Without `AIR_MDR_API_KEYS`, the API permits only loopback socket addresses and loopback Host names. The CLI binds `127.0.0.1:4173` by default and refuses a nonloopback bind without keys. Do not expose local mode through a proxy or tunnel.

For server/agent mode, set `AIR_MDR_API_KEYS` to a JSON array of `{ "key": "at-least-24-random-characters", "workspaceId": "team-a", "name": "Team A" }`. Send `Authorization: Bearer KEY`. Use cryptographically random keys from a secret manager. Each key maps to one workspace; callers cannot select another workspace in a request. Multiple keys may map to the same workspace. Environment bootstrap keys have owner authority. Member sessions and managed agent keys enforce owner/editor/viewer roles; see [IDENTITY.md](IDENTITY.md) for bootstrap, login, invitations, CSRF, member management, scoped keys and audit contracts. `NODE_ENV=production` refuses startup without keys. The public share endpoint and permitted static assets are unauthenticated. Production denies legacy profiles/exports directories unless AIR_MDR_ALLOW_LEGACY_STATIC=1 explicitly exposes them; tenant demos belong in the database.

Browser API calls must be same-origin. Errors have `{ "error": "message" }`; requests return `X-Request-Id`. JSON responses use `Cache-Control: no-store`. Body limit: 1 MiB. Per-process rate limit: 300 API requests per remote IP per minute. Environment keys are never returned by the API; a newly created managed agent key is returned once. Workspace configuration changes require restart.

## Demo model and concurrency

A demo is `{id,title,company,profile,tour,productState,research,status,revision,createdAt,updatedAt}`. Server assigns identity, monotonically increasing revision, and ISO timestamps. Status is `draft`, `reviewed`, or `archived`. A reviewed label is caller-supplied, not automatic verification.

`profile` is the existing product personalization object. `productState` is a JSON object for simulated product state. `research` is private JSON including imported source material. A tour is `{version:1,title,steps:[{id,screen,target?,title,body,placement?,advanceOn?}]}`. Up to 100 steps; unique IDs; screens must be registered product screen IDs. Profile/selector values remain data, never executable code. The API limits serialized demos to 750,000 bytes, title to 240 characters, company to 200, step title to 300, step body to 10,000, and target to 500. The renderer still needs to handle unknown/absent fields gracefully.

PUT replaces all editable fields. Supply `expectedRevision` in JSON or `If-Match: "N"`. DELETE requires `If-Match`. Missing/invalid preconditions return 428; stale revisions return 409. Reload and resolve the conflict before retrying. Historical revisions are immutable; to restore one, GET it then PUT its editable fields with the current revision.

| Method | Path | Request | Response |
|---|---|---|---|
| GET | `/api/session` | — | `{workspace:{id,name},role,user,authMode,csrfToken?,expiresAt?}` |
| GET | `/api/demos` | — | `{demos:[{id,title,company,status,revision,createdAt,updatedAt}]}`; latest 200 |
| POST | `/api/demos` | `{title,company?,profile?,tour?,productState?,research?,status?}` | 201 `{demo}` |
| GET | `/api/demos/:id` | — | `{demo}` and ETag |
| PUT | `/api/demos/:id` | editable fields plus expectedRevision | `{demo}` and ETag |
| DELETE | `/api/demos/:id` | If-Match header | 204; cascades revisions/shares |
| GET | `/api/demos/:id/revisions` | — | `{revisions:[{revision,createdAt}]}`; latest 100 |
| GET | `/api/demos/:id/revisions/:revision` | — | `{demo}` historical data |

No pagination currently; older demos remain addressable by known ID. Workspace filtering applies to demo, revision, share, and job operations, including IDs obtained elsewhere.

## Research compilation

`POST /api/compile` accepts `{company?,domain?,researchDump:string|object}` and returns `{profile,tour,research,warnings}`. It does not browse, call an LLM, or execute instructions in the dump. JSON strings are parsed; plain text is used as business context. Preferred object shape:

```json
{
  "company": "Example Company",
  "domain": "example.com",
  "description": "Provided business context",
  "facts": [
    {"statement":"A supplied business fact", "url":"https://example.com/about", "retrievedAt":"2026-10-09T12:00:00Z"}
  ]
}
```

String dumps: at most 262,144 characters; object serialized length: at most 300,000 characters. Up to 100 facts are retained. Imported facts are marked `verification: "provided-unverified"`; invalid source URLs become blank. `research` retains `raw`, `facts`, `description`, `kind: "imported"`, `reviewStatus: "unreviewed"`, and `importedAt`. Missing citations create warnings. Generated incident/account/bucket/IP narratives are explicitly synthetic; import compilation does not establish a real security incident or technology stack. The eight-step generated tour covers dashboard → alerts → case management → what happened → alternatives considered → generated playbook → execution → case management. The selected industry recipe customizes the narrative and targets. Review context, citations, and tour targets before sharing.

The compatibility `POST /api/research` accepts `{domain}`, submits through the durable queue and waits up to 95 seconds for `{profile,tour,research,warnings}`. If it does not complete, the error identifies the job to inspect; do not blindly resubmit. Prefer the asynchronous job endpoints. Both paths use the same quotas.

### Persisted jobs

Author roles (owner/editor) can submit, cancel or retry. Viewers can list jobs and read results within their workspace.

| Method | Path | Request | Response |
|---|---|---|---|
| POST | `/api/research-jobs` | compile input or `{domain}` | 202 `{job}` |
| GET | `/api/research-jobs` | — | `{jobs,quota:{day,used,limit}}`; latest 50, result payloads omitted |
| GET | `/api/research-jobs/:id` | — | `{job}` including result when succeeded |
| POST | `/api/research-jobs/:id/cancel` | `{}` | `{job}` with cancelled status |
| POST | `/api/research-jobs/:id/retry` | `{acknowledgeUnknown?:boolean}` | 202 `{job}` |

Job fields: `{id,label,kind,status,attempts,maxAttempts,result?,error,errorCode,manualRetryRequired,nextAttemptAt,leaseExpiresAt,createdAt,updatedAt}`. `label` contains the sanitized input domain or company name. `kind` is `compile` or `provider`. States: queued, running, retrying, succeeded, failed, cancelled. Result: `{profile,tour,research,warnings}`. Stored queue results omit raw imported dumps; keep original research privately when needed. Polling/listing never dispatches new work.

SQLite transactions coordinate global and workspace worker claims, leases, daily UTC request allowances and bounded retries. Safe deterministic failures can retry; ambiguous provider outcomes become failed with `manualRetryRequired:true` and never retry automatically. Review provider usage/results, then pass `acknowledgeUnknown:true` only when authorizing another potentially billable attempt. All attempts share a limit. Cancellation cannot guarantee stopping an upstream request or refunding credits.

Default limits: 4 running jobs globally, 2 per workspace, 50 pending per workspace, 20 provider-attempt reservations per UTC day and 3 attempts per job. A reservation is not a billing measurement. See [RESEARCH-JOBS.md](RESEARCH-JOBS.md) for state transitions, recovery and limits.

The Studio’s Research jobs dialog exposes progress, quota, cancellation, reviewed retries and result application. Results never automatically replace an in-progress draft. A result can be reviewed, applied and undone in the same page session; saved revisions change only on save.

## Public shares

POST `/api/demos/:id/shares` with `{expectedRevision}` (or If-Match) returns 201 `{share:{id,token,url,revision,createdAt}}`. `url` is `/index.html?product=1&present=1&share=TOKEN`. The random 256-bit token is returned once; only its hash is stored. Copy/store the URL when created.

GET `/api/demos/:id/shares` returns `{shares:[{id,revision,createdAt,revokedAt}]}` without bearer tokens. DELETE `/api/demos/:id/shares/:shareId` revokes it (204). GET `/api/public/:token` requires no API key and returns `{demo}` pinned to the shared revision. Subsequent edits do not alter that snapshot. The snapshot explicitly projects known render fields. Raw `research`, unknown metadata, and product event history are excluded; profile facts and authored demo copy remain public. Review the profile before sharing. Revocation prevents future API retrieval; it cannot remove copies already downloaded or an already loaded viewer. Deleting a demo invalidates all its shares.

## Validation

Run `node scripts/backend-test.mjs`. The suite uses isolated temporary SQLite databases and mocked live research; it does not contact a provider or deploy. It covers API auth, tenant separation, optimistic concurrency, revision history, persistence/restart handling, import compilation, worker status, public snapshot/revocation, input limits, and private-file exclusions. See `BACKEND-DEPLOYMENT.md` for operational gaps.

## Hosted Eve extension

The Vercel/Postgres API and durable research-run endpoints are documented in [HOSTED-EVE-API.md](HOSTED-EVE-API.md). It differs from the local server in persistence, shared rate counters and research execution: the hosted legacy research queue is unavailable; the separate Eve runtime provides company research.

### Native runtime state validation

Native AirMDR demos with `productState.version: 1` are validated before create/update writes. Invalid case records, narratives, tasks, playbook snapshots, activation rules or retained execution data return HTTP 400 with a `productState.<field>` error path. Rejected writes create no revision and leave the previous draft unchanged. Limits include 100 cases, 200 playbook models, 500 retained runs, 500 records per nested collection and 200 supporting-run references per run. The overall demo payload limit still applies.

Empty and versionless legacy states retain their existing behavior: the preview resets runtime state. Declarative product templates use their separate manifest/collection validator. Save-time native validation checks structure; a dependency graph may remain an unfinished draft. Run with dependencies separately checks missing targets, cycles, publication and selected-case compatibility before execution. This validation does not verify the truth of research claims.

### Native playbook step conditions

Version-1 native `playbookModels.<id>.steps` and `publishedSteps` entries may include an optional `condition` object:

```json
{"id":"review-containment","text":"Review containment with the owner","condition":{"field":"Case severity","operator":"Equals","value":"High Severity"}}
```

Allowed fields: Always, Alert type, Case severity, Case status, Case disposition. Operators: Equals, Contains, Does not equal. Non-Always values must be nonempty text. Rules are case-insensitive and whitespace-trimmed at execution. No script or general expression is evaluated. Preserve the rest of the exported model when editing; this fragment is not a complete demo payload.

Retained run steps optionally carry the original `condition` and `evaluation` (`field`, `operator`, `expected`, `actual`, `matched`). Unmatched step outputs use `status: "skipped"`. An all-unmatched run is `simulated-skipped`; historical evaluations remain snapshots and are not re-evaluated when read. The public projection preserves these typed fields. Invalid condition shapes are rejected before save.
