# Shared research gateway API v1

`server/research-gateway.mjs` exports `handleApi(request, env) -> Promise<Response>`. Its only binding is `env.DB`, a Cloudflare D1 database supporting prepared statements and transactional `batch`. Import `observer-core.mjs` alongside it. Use `db/schema.ts` to generate the host's migration; deployment migrations are in `drizzle/`. Tests use `tests/schema.sql`.

Deploy behind the trusted Sites dispatch boundary, which overwrites the `oai-authenticated-user-id` and `oai-authenticated-user-email` headers. Both nonempty headers are required for human routes. Do not expose this module on an alternate URL that accepts client-provided identity headers. Request bodies never supply the acting human identity. Human POSTs require `Origin` equal to the request URL's origin. There is no permissive CORS configuration, public mutation mode, arbitrary URL fetch, shell execution, or arbitrary recipe execution.

All paths below have prefix `/api/research`. All POSTs take `Content-Type: application/json`. No query parameters are accepted. Unknown request fields are rejected. JSON responses have `Cache-Control: no-store`. Timestamps are Unix milliseconds. Errors are `{error:{code,message}}`, with no SQL or secret details.

## Human routes

| Method / path | Body | Response |
|---|---|---|
| GET `/me` | — | `{authenticated:true,user:{userId,email},limits,modelVersion}`; anonymous `401 sign_in_required` |
| GET `/projects` | — | `{projects:[{id,name,role,createdAt,updatedAt}]}` |
| POST `/projects` | `{name}` | `201 {project}` |
| GET `/projects/:id` | — | `{project,members,agents,tasks,audit,auditCoverage}` (latest 100 events) |
| GET `/projects/:id/export` | — | Export v2 with all retained events (up to 500) and explicit coverage |
| POST `/projects/:id/invites` | `{role:'member'|'viewer',expiresInHours:1..168}` | `201 {inviteToken,expiresAt,role}` |
| POST `/projects/:id/invites/revoke-all` | `{}` | `{revokedCount}` |
| POST `/join` | `{inviteToken}` | `201 {project}` |
| POST `/projects/:id/members/remove` | `{userId}` | `{removedUserId}` |
| POST `/projects/:id/agents` | `{name,expiresInHours:1..720}` | `201 {agent,token}` |
| POST `/projects/:id/agents/:agentId/revoke` | `{}` | `{agent}` |
| POST `/projects/:id/tasks` | `{agentId,recipe:'observer_projection',params,browserEvidence?}` | `201 {task}` |

Owners issue/revoke invitations, remove members/viewers, issue/revoke agent credentials, and create tasks. Members create tasks. Viewers only read. All project reads require membership. The owner cannot be removed. Removing membership preserves task and audit history, prevents further reads/writes, and does not reactivate any used invitation. A fresh invitation may grant access again. Task insertion rechecks membership atomically, including when removal races an in-flight request. An invitation may be redeemed once, before expiry, by a signed-in user who is not already a member. Revoking all invitations deletes every unused invitation in that project. The gateway does not send invitations or messages. Task reassignment and role changes are outside this version.

An agent token is explicitly bound to one project and the `observer_projection` recipe. `token` and `inviteToken` are shown only in their creation response; only their SHA-256 hashes are stored. Agent credentials are never returned by project reads. Revocation immediately disables the bearer and invalidates outstanding leases. Outstanding tasks remain visibly assigned to the revoked agent and cannot be completed with that credential.

Names contain 1–80 printable characters. A user belongs to at most 20 projects; each project has at most 50 members, 100 total tasks, 10 total issued agent credentials, 20 active unused invitations, and 500 retained audit events. Detail reads show the most recent 100 audit events. The export endpoint returns all currently retained records, with `auditCoverage` and an export timestamp; it is not an atomic archival snapshot and cannot restore events already removed by the retention limit. Expired/consumed invitation records are removed when issuing another invitation. No endpoints allow an unbounded user-supplied blob.

`members` contains `{userId,email,role,joinedAt}`. `agents` contains `{id,name,recipe,createdAt,expiresAt,revokedAt,lastSeenAt,connected}`. Connected means a nonexpired, unrevoked credential actually polled, completed, or heartbeated within the last 90 seconds; issuance alone does not mean connected.

`task` contains `{id,projectId,agentId,recipe,params,browserEvidence,state,createdBy,createdAt,updatedAt,leaseExpiresAt,attemptCount,completedAt,result,resultHash,sourceHash,provenance,verification}`. State is `queued`, `running`, or `completed`. Result/hash/provenance/verification fields are null until completed, except that absent optional provenance also leaves `sourceHash` null. `audit` entries contain `{id,actorType,actorId,action,objectId,createdAt,details}`.

## Optional browser evidence reference

A task may supply `browserEvidence: {taskId, bundleHash, parameterHash}`. Hashes must be lowercase SHA-256 hex strings. `parameterHash` must equal the hash of canonical JSON of the full ten task parameters. The normal UI retrieves and verifies the local signed bundle before sending this reference; if it is missing or altered, the user must record it again or explicitly choose a parameters-only task.

The gateway persists this as `status: reference_only`, `parameterBinding: verified`, `signatureVerification: not_performed_by_gateway`. A matching parameter hash does not validate the original bundle or its scientific claim. The full signed bundle remains in the browser and must be exported separately. Existing tasks have `browserEvidence: null`; no historical association is invented.

## Agent routes

Each agent request has `Authorization: Bearer <token>`. No human login is required for bearer routes. If an Origin header is supplied, it must be same-origin. Invalid, expired, or revoked tokens receive `401 agent_unauthorized` on every endpoint, including completion replay.

| POST path | Body | Response |
|---|---|---|
| `/agent/heartbeat` | `{}` or `{taskId,leaseToken}` | `{agentId,connected:true,lastSeenAt,leaseExpiresAt:null|number}` |
| `/agent/next` | `{}` | `{task:null,busy:boolean}` or `{task,leaseToken,leaseExpiresAt}` |
| `/agent/tasks/:taskId/complete` | `{leaseToken,result,provenance?}` | `{task,idempotent:boolean}` |

An atomic lease assigns one eligible task explicitly addressed to that agent. There is at most one unexpired running lease per agent. Leases last two minutes. Expiry gates use the later of the captured request clock and SQLite's current UTC millisecond clock at statement execution; a delayed database operation cannot reuse an earlier authorization time to pass an expired token, invitation or lease. Stored request timestamps are not an attested physical clock. Heartbeat with the matching task ID and lease token renews an unexpired lease for two minutes. Empty heartbeat only records connectivity. Expired leases may be reacquired with a new lease token; old tokens then fail. Lease tokens are random, returned only on acquisition, and stored only as hashes. Poll no faster than every few seconds when idle.

Completion requires the assigned bearer, matching unexpired lease, and exact schema below. Successful completion is immutable. Replay of the same lease and canonically identical result/provenance returns the stored result with `idempotent:true`; a different payload or lease returns `409`. Concurrent identical completions produce one completion audit event. Expired/revoked credentials cannot replay a completion. A malformed result is rejected; a well-shaped numerical mismatch is retained with the explicit `supplied_by_agent` status.

```json
{
  "leaseToken": "rl_<64 lowercase hexadecimal characters>",
  "result": {
    "modelVersion": "fixed-world-observers/1.0.0",
    "observations": {
      "a": {"projectedSeparation": 0.1, "centerDistances": {"left": 6.0, "right": 6.0}},
      "b": {"projectedSeparation": 0.2, "centerDistances": {"left": 6.0, "right": 6.0}}
    },
    "worldSeparation": 1.833712082
  },
  "provenance": {
    "agentType": "deterministic_external_python",
    "sourceSha256": "<64 lowercase hexadecimal characters>",
    "implementation": "independent_focal_plane_geometry",
    "evidenceClass": "synthetic_model_consistency"
  }
}
```

The numerical values above are schema examples, not expected values. `projectedSeparation` is Euclidean image-plane center separation divided by viewport width, with vertical normalized coordinates corrected for aspect ratio 1.5. `centerDistances` and `worldSeparation` are in meters. All numbers are finite and between 0 and 1,000,000. The server uses `projectWorld(task.params)` to recompute all seven measurements and compares with relative tolerance `1e-9 * max(1, abs(expected))`.

`verification` contains `{status,agrees,checks,maxAbsoluteError,tolerance,modelVersion,errors,description}`. Status is `verified_synthetic` only when all seven numbers agree; otherwise it is `supplied_by_agent`. This verifies consistency with the specified synthetic geometry model. It does not prove any physical theory, attest what program ran, or validate external observations.

`resultHash` is the server's SHA-256 of the canonical submitted reduced result. `sourceHash` is optional agent-reported `provenance.sourceSha256`; its provenance is explicitly marked `supplied_by_agent`. Canonical JSON recursively sorts object keys and preserves array order. The server stores bounded result JSON (maximum 32 KiB), normalized provenance, actor identity, receipt/completion timestamps, lease attempt count, verification, and audit events. Entire request bodies are limited to 40,000 bytes during streaming reads.

## Observer parameters

The task `params` must contain exactly these ten keys, without coercion:

```json
{
  "yaw_a_deg": 0, "pitch_a_deg": 10, "distance_a_m": 6, "fov_a_deg": 55, "projection_a": "perspective",
  "yaw_b_deg": 65, "pitch_b_deg": 10, "distance_b_m": 6, "fov_b_deg": 55, "projection_b": "perspective"
}
```

Yaw is −175 to 175 degrees; pitch −65 to 65; distance 3 to 12 meters; field of view 25 to 100 degrees; projection is `perspective` or `orthographic`.

## Verification

Run `node --test tests/gateway.test.mjs` on Node 24. The tests execute the real gateway against a transactional `node:sqlite` D1 shim and cover authentication, origin/JSON guards, membership isolation/removal, invitation revocation, task creation racing membership removal, one-use expiring invites, hash-only credential storage, exact schemas, bounded concurrent creates, exclusive lease acquisition, lease renewal/expiry, revocation, delayed-body expiry, independent result labels, provenance, idempotency, and audit integrity. The gateway itself has no Node dependency and runs in a Worker.
