The FlowQA API
Implemented in apps/api/src/flowqa on flowqa/desktop-cloud.
This is the persistent authoring foundation for the dedicated web and desktop frontends.
It does not allocate browsers, execute tests, deliver secrets, or change the desktop preview's local save behavior.
Identity and permissions
Every route uses Snag's existing bearer authentication, including sessions, OAuth access tokens, and PATs. The workspace comes from the authorized project, never the request body. Deleted projects and workspaces, cross-workspace access, and membership/PAT project restrictions return 404. Workspace reporters, including virtual Snag Feedback reporters, have no QA access. Admins and permitted members can read tests and create their own drafts. Only admins can enable or update QA configuration. Every mutation enforces PAT write scope. Draft reads and commands are restricted to the author, even when another project member is an admin. Membership and token changes take effect through the existing authentication guard on the next request.
Routes
All paths below start with /v1/projects/:projectId/qa.
| Method | Path | Contract |
|---|---|---|
| GET | /configuration |
Return current revision and configuration; 404 when QA is not enabled. |
| PUT | /configuration |
Admin write with expected_revision and configuration; revision zero enables QA. |
| GET | /tests |
Published-test summaries; limit 1 to 100 and optional after cursor. |
| GET | /tests/:testId |
Current metadata and complete Flow definition. |
| GET | /tests/:testId/revisions/:revision |
Read an immutable published revision. |
| GET | /drafts |
The caller's open draft summaries, with the same pagination contract. |
| POST | /drafts |
Create or recover a draft with a user/project-scoped idempotency_key. |
| GET | /drafts/:draftId |
Recover the complete draft and last accepted checkpoint sequence. |
| PUT | /drafts/:draftId |
Replace draft content using the next sequence and complete definition. |
| POST | /drafts/:draftId/save |
Publish the reviewed expected_sequence, checking the draft's base test revision. |
| POST | /drafts/:draftId/discard |
Discard the reviewed expected_sequence; published tests remain intact. |
Responses use Snag's standard error envelope. Revision, checkpoint, and duplicate-identity conflicts return 409. Invalid definitions and broken shared-step graphs return 422.
Configuration example
{
"expected_revision": 0,
"configuration": {
"app": "checkout",
"environments": [
{
"id": "preview",
"name": "Preview",
"baseUrl": "https://preview.example.com",
"allowedHosts": ["preview.example.com"],
"readOnly": false
}
],
"defaultEnvironment": "preview",
"modules": ["checkout"],
"suites": []
}
}
The QA app identity is fixed after initial configuration, while environment and organization settings use revision-checked updates.
Configuration is an explicit hosted format, not an upload of a local config.json.
Unknown fields, local auth-file declarations, seed commands, and a secrets payload are rejected.
Base URLs allow HTTP and HTTPS and reject embedded username/password credentials.
Target-application credentials will use a separate authorized secret-delivery path.
Draft and save lifecycle
Create a new draft with base_revision: 0 and no test_id.
For an edit, supply the current test_id and base_revision.
An existing-test draft must start from the current published revision.
Both forms require a complete Flow definition and an idempotency key.
For example:
{
"idempotency_key": "recording-request-7c934dc0",
"base_revision": 0,
"definition": {
"ir": 1,
"app": "checkout",
"flow": "checkout/guest",
"name": "Guest checkout",
"platform": "web",
"steps": [{ "do": "navigate", "url": "/" }]
}
}
The response contains draft_id, sequence: 0, and status: "open".
Checkpoints must advance by exactly one sequence.
Repeating the same accepted sequence with the same normalized definition succeeds without another write.
A different payload at that sequence, an older sequence, or a skipped sequence conflicts.
Creating a draft again with the same key and creation payload recovers its current state.
Reusing the key with different creation content conflicts.
Save with { "expected_sequence": 3 } only after the user reviews checkpoint three.
The transaction checks project access, draft ownership, the checkpoint sequence, and the published test's base revision.
It then validates the resulting shared-step graph, creates a revision, updates the test head, marks the draft saved, and writes the audit record.
If any part fails, all changes roll back and the draft remains available.
Retried successful saves return the exact originally published revision, even after later edits have been published.
On a stale-test conflict, read the latest test and its historical base revision and compare them with the preserved draft. A client can create a new draft based on the latest revision after the user resolves the differences. There is no force-overwrite or implicit rebase operation. A draft's flow identity is fixed; resolving a new-test identity collision requires creating a new draft with a different flow ID. Changing the display name is a regular edit and increments the test revision even when its behavioral content hash stays the same.
Storage and validation
The additive migration creates QaProjectConfig, QaTest, QaTestRevision, and QaRecordingDraft.
Drafts reference tests through a composite project/test foreign key.
Authoring writes serialize on the project's database row and recheck access after acquiring the lock.
Test-head updates also match the expected revision explicitly.
Reads use immutable revision rows, so a current-test response does not combine different revisions.
@snag/flowqa-contracts owns the existing Flow schema, types, and content-hash implementation.
The CLI and recorder re-export those same definitions for compatibility.
The API imports this package without importing Playwright or Electron.
Client-supplied content/review hashes are not authoritative; publication computes the content hash and does not grant review approval.
Hosted limits are 1 MB of JSON and 2,000 written steps per definition, 5,000 tests per project, 50,000 distinct dependency edges per project, five include levels, and 10,000 expanded steps per test. Flow IDs use slash-separated letters, digits, underscores, and hyphens, up to 240 characters. Shared-step validation reads compact metadata from test heads and memoizes counts instead of loading or expanding every full definition. It checks missing references, cycles, platform mismatches, and changes that would invalidate another published test. References may be incomplete in an open draft; they must be valid before publication.
Verification and remaining integration
The migration chain was applied to disposable local PostgreSQL databases and checked against the final Prisma schema without drift.
HTTP integration tests use real PostgreSQL and Snag's normal authentication guard.
They cover authorization, concurrent edits/checkpoints, retries, audit rollback, shared-step validation, soft deletion, and recovery after restarting the API.
Tests must use a dedicated TEST_DATABASE_URL; existing API suites truncate fixture tables.
pnpm --filter @snag/shared build
pnpm --filter @snag/api exec prisma generate
pnpm --filter @snag/api build
TEST_DATABASE_URL=postgresql://snag:snag@localhost:5455/snag_test pnpm --filter @snag/api test
Container manifests include the contracts package in both build and runtime stages. The container's pinned pnpm 10.22 validates the frozen lockfile in a manifests-only check. A Docker image was not built in this environment because no Docker daemon is installed. No migration or API deployment has been applied to a shared environment.
Desktop PKCE login, online draft upload, the shared frontend, product import/reconciliation, secrets, execution snapshots, durable jobs, artifacts, and remote browser viewing remain separate implementation steps.
Desktop project discovery and recording snapshots
GET /v1/qa/projects returns QA-enabled, live projects in live workspaces for the caller's admin/member memberships and project restrictions.
Each entry contains project_id, name, workspace_id, workspace_name, and app.
Reporter memberships do not contribute projects.
This is a read endpoint, so read-only PATs can discover their permitted projects without gaining write access.
Draft publication accepts two additional optional guards:
{
"expected_sequence": 0,
"expected_configuration_revision": 3,
"expected_dependencies": [
{ "flow_id": "shared/signed-in-preview", "revision": 5 }
]
}
The desktop collects these revisions after verifying the local environment and the complete referenced reusable-opening graph against hosted definitions. The API compares them under the same project lock used for configuration changes and publication. A mismatch returns conflict and keeps the draft open. These guards describe the snapshot used to prepare the recording, not permanently pinned execution dependencies. An already successful publication returns its original result on retry even if these dependencies subsequently change.
Execution API
Under /v1/projects/:projectId/qa/executions, POST creates an idempotent execution using idempotency_key, target (local or cloud), environment, and tests containing test_id and revision.
GET lists recent executions; GET /:executionId returns results and pinned revision metadata without the assignment bundle or lease.
POST /:executionId/cancel permits the requester or a project administrator to cancel unfinished work.
Local assignments use POST /:executionId/claim with worker_id and runtime: { protocol: 1, playwright: "1.62.1" }.
Only the original requester can claim a local execution.
POST /:executionId/heartbeat renews the returned lease; POST /:executionId/complete publishes lease, status, results, and optional error.
Completion retries must match the originally acknowledged result.
Standalone workers use /v1/qa/worker/claim, then /v1/qa/worker/:projectId/:executionId/heartbeat, /complete, and /artifacts.
They authenticate with FLOWQA_WORKER_TOKEN; FLOWQA_WORKER_PROJECTS is either * (every project) or an explicit comma-separated project allowlist.
Unset or empty means no project, and the same rule applies to the claim and to every per-execution worker route.
The worker runtime is apps/flowqa-worker/src/main.mjs and requires FLOWQA_API_ORIGIN and FLOWQA_WORKER_TOKEN.
FLOWQA_API_ORIGIN must be HTTPS, except loopback and single-label private hostnames such as the Docker service name http://snag-api:4400.
FLOWQA_ALLOW_INSECURE_ORIGIN=1 lifts that rule for local development only.
The container build and runbook live in deploy/flowqa-worker/.
GET /v1/qa/cloud/status reports cloud browser availability to any signed-in caller (session, OAuth or PAT) as { browsers: { total, available, busy }, updated_at }.
Each worker provides one browser and counts as online when the API heard from it (claim poll, heartbeat or completion) in the last 15 seconds.
A worker is busy from the claim that assigns it a run until it completes, or until a user cancels that run.
The registry is held in memory by the single API instance, so an API restart repopulates it within seconds as workers poll again.
Worker credentials belong only in the supervisor process, never the browser or its execution child.
Deployment must provide network and process isolation; the worker executable alone does not enforce a cloud network boundary.
Every execution carries origin (manual, mcp, schedule or trigger), origin_id (the schedule or trigger that started it) and an optional label (what a CI trigger passed, for example a deploy ref).
Pairing with a Snag project
A FlowQA project pairs with one Snag project in the same workspace, and the pair is what makes the loop one click: a failing run files its snag into the paired project, and a snag there reproduces as a test here.
GET /v1/projects/:id/linkreturnslinked_project(or null) and, unpaired, thecandidatesof the other product the caller may use.PUT /v1/projects/:id/linkwith{ project_id }pairs; both projects must be in the same workspace, of different products, and unpaired.DELETEunpairs. Both need write scope.- In the workbench the pair lives in Settings, Project, as the Snag project row (hosted projects only; project commands
link,link-pair,link-unpair). In the Snag portal it is the "Pair with FlowQA" button on the project page, for admins who hold FlowQA in the workspace.
Schedules and CI triggers
Under /v1/projects/:projectId/qa/schedules: GET lists, POST creates (name, five-field cron, timezone, environment, optional test_ids (empty means every recorded test), enabled, alert_emails, alert_slack_channel, alert_on of failure or always), PATCH /:id changes any of those, DELETE /:id removes, POST /:id/run starts the schedule's selection now.
A runner fires due schedules once a minute as the schedule's creator; a slot whose previous run is still going is skipped and recorded in last_error.
Under /v1/projects/:projectId/qa/triggers: GET lists (never the token), POST creates and returns the token once, DELETE /:id revokes.
The public routes POST /v1/qa/triggers/:token/run (body: optional environment, tests as flow ids, label) and GET /v1/qa/triggers/:token/executions/:id (status, done, ok, summary, results) need no other credential; a wrong or revoked token is a 404.
Runs from schedules and triggers alert the people named on them when they fail; see docs/flowqa-ci.md for the pipeline recipe.
POST /:executionId/artifacts uploads an assignment-owned PNG with lease, safe relative path, SHA-256 checksum, base64 data, and content_type: "image/png".
GET /:executionId/artifacts?path=... requires live project access and returns verified bytes as base64.
Individual PNGs are limited to 3 MB; execution evidence is limited to 2,000 files and 200 MB.
Large/unsupported evidence causes an explicit upload failure rather than silent omission.
Retention and orphan-object cleanup remain release work.