# BobSwap agent reference

Public REST API for durable workspace seeds and a zero-storage browser-to-browser relay. API version: 1.0.0. This reference describes the current implementation; client responsibilities and unfinished features are marked explicitly.

## Connection and authentication

Base origin: `https://bob-snapshot-bridge.lovable.app`. API prefix: `/api/v1`. Preview and published versions may differ until the latest changes are published. Use the origin hosting this document when testing a preview.

`GET /api/health` requires no key and returns `{"ok":true,"version":"1.0.0"}`.

Seed clients authenticate with `Authorization: Bearer <API key>`. Keys start with `bsk_live_` followed by 32 lowercase hexadecimal characters. Email/password login is exclusively for the operator dashboard, not API clients. There is no public signup, account-management endpoint, or key-management endpoint. The dashboard and key-creation UI are not built yet; obtain an already-provisioned key from the operator. Never invent a key or embed one in a public bundle.

Scopes are cumulative:

- `read`: read seeds, metadata, lists, and create relay offers.
- `upload`: all read operations plus seed PUT.
- `manage`: all upload operations plus delete seeds and create signed links.

Keys are account-bound for seed requests. Missing, invalid, expired, revoked, wrong-account, and insufficient-scope keys return `401 {"error":"key required"}`. Stored hashes are verified with a constant-time comparison.

`?key=<API key>` is supported only on seed GET/HEAD requests. Any write containing that parameter is rejected with `403 {"error":"read-only scope in URL"}`, even with a valid header. A URL key grants read-only access even if the underlying key has a higher scope. Prefer headers: URLs may enter browser history, copied links, or external logs. Pairing codes must never be placed in URLs.

CORS origin is `*`. Allowed methods advertised by preflight: GET, PUT, POST, DELETE, OPTIONS. Allowed headers: authorization, content-type, if-none-match. Exposed response headers: ETag, X-Seed-Version, Content-Length. HEAD works for archives and signed links, but is not advertised in the current preflight methods.

## Archive and version rules

An archive is UTF-8 JSON, not a ZIP. Required fields:

```json
{
  "version": 1,
  "createdAt": "2026-10-23T01:00:00Z",
  "files": [
    { "path": "notes/hello.txt", "base64": "SGVsbG8K" }
  ]
}
```

`version` is the archive format number, exactly 1; it is not the version label in the URL. `createdAt` must be a parseable timestamp. Each file needs a nonempty relative path and string `base64`. Absolute paths, backslashes, empty path segments, `.` and `..` segments are rejected. Invalid archives return 400 naming the first invalid entry. The current server checks that base64 is a string, not that its contents decode correctly; clients must validate decoding themselves.

Account and seed names: 1–64 lowercase letters, digits, `.`, `_`, `-`. Version labels: 1–100 letters (either case), digits, `.`, `_`, `-`. `latest`, `versions`, `meta`, `link` are reserved labels.

Recommended label: `<sha256-first-12-hex>-<YYYYMMDD>T<HHMMZ-or-HHMMSSZ>`, for example `0123456789ab-20261023T010000Z`. Compute SHA-256 over the exact UTF-8 archive bytes. When a label matches this format, a mismatched SHA prefix returns 409. Other valid labels such as `v1` are allowed.

Maximum archive request body is 209715200 bytes (200 MiB), including JSON and base64 overhead. Hosting or storage may impose lower practical limits; archives near this maximum have not been verified. Preserve exact bytes for retries: even JSON whitespace changes the SHA and may cause a conflict.

## Seed quick start

Set these shell variables before running the examples. `KEY` is a key issued by the operator; replace the marked value. This example archive is only a smoke-test file, not a full BOB workspace.

```bash
BASE='https://bob-snapshot-bridge.lovable.app'
ACCOUNT='personal'
SEED='demo'
VERSION='v1'
KEY='<operator-issued API key>'

cat > workspace.json <<'JSON'
{"version":1,"createdAt":"2026-10-23T01:00:00Z","files":[{"path":"notes/hello.txt","base64":"SGVsbG8K"}]}
JSON

curl -i -X PUT "$BASE/api/v1/$ACCOUNT/$SEED/$VERSION" \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @workspace.json

curl -f "$BASE/api/v1/$ACCOUNT/$SEED/latest" \
  -H "Authorization: Bearer $KEY" \
  -o downloaded-workspace.json
```

PUT requires upload or manage scope. New bytes at a new version return 201. Identical re-PUT returns 200. Different bytes at the same version return 409; choose a new label, do not overwrite. Success body is `{"url":"…","meta":{…}}`.

## Seed endpoints and caching

All paths below are relative to `/api/v1` unless stated otherwise.

- `PUT /{account}/{seed}/{version}` — upload raw JSON, upload scope. 201 new / 200 identical / 409 conflict.
- `GET /{account}/{seed}/{version}` — retrieve the byte-identical archive, read scope. HEAD returns headers without the archive body.
- `GET /{account}/{seed}/{version}/meta` — tiny metadata, read scope.
- `GET /{account}/{seed}/versions` — metadata array ordered by newest `uploaded_at`, read scope.
- `GET /{account}/{seed}/latest` — resolve the most recently uploaded version, not the greatest label or newest `createdAt`. Includes `X-Seed-Version` with the actual label. HEAD supported.
- `GET /{account}/{seed}/latest/meta` — metadata for that resolved version.
- `GET /{account}/seeds` — `{"account":"…","seeds":[{"name":"…","latest":"…","versions":[…]}]}`, read scope.
- `DELETE /{account}/{seed}/{version}` — delete that version, manage scope, returns `{"deleted":N}`. Adding `?purge_name=1` deletes ALL versions under the seed name, not just the label in the path. Do not use it for routine single-version cleanup.

Metadata fields: `version` (format number 1), `createdAt`, `fileCount`, `size` (archive bytes), `sha256`, `seed_id_and_version` (URL version label), `uploaded_at` (server timestamp).

Archive responses use `Content-Type: application/json`, `Cache-Control: private, must-revalidate`, and an `ETag` of the quoted full SHA-256. Metadata also carries an ETag. Send the previously saved ETag with `If-None-Match`; unchanged content returns 304 with no body. Keep your local archive on 304. Do not parse a 304 as JSON. Deleting the current latest version causes `/latest` to resolve to the next newest upload.

```bash
curl -i "$BASE/api/v1/$ACCOUNT/$SEED/latest/meta" \
  -H "Authorization: Bearer $KEY"

# Replace the marked digest with your saved response ETag.
curl -i "$BASE/api/v1/$ACCOUNT/$SEED/latest" \
  -H "Authorization: Bearer $KEY" \
  -H 'If-None-Match: "<previous full SHA-256>"'
```

## Signed share links

`POST /{account}/{seed}/{version}/link` requires manage scope. Optional JSON body: `{"ttl_hours":24}`. Default 24 hours; allowed range 0–720 hours. Zero means no expiry. Returns 201 with `url` and `expires_at` (null for no expiry).

```bash
curl -i -X POST "$BASE/api/v1/$ACCOUNT/$SEED/$VERSION/link" \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"ttl_hours":24}'
```

The returned `/g/{token}` URL is an HMAC-signed download capability. GET/HEAD needs no API key. Treat the entire URL as a secret: anyone holding it can retrieve that one archive. It serves JSON, not an import UI. Expired or tampered links return 401 in the current implementation; a valid link whose archive has been deleted returns 404. Revoking a seed key does not revoke an already-issued signed link; expiry or deleting its target archive removes access.

## Relay pairing API

Relay HTTP endpoints manage pairing metadata only. They NEVER accept workspace content or file chunks. Workspace bytes travel exclusively over an ephemeral realtime broadcast connection between clients. The service is a relay, not a direct peer-to-peer connection. The browser harness and production BOB client integration remain unfinished.

`POST /relay/channels` requires a header API key with read scope or higher. Required JSON fields:

```json
{
  "mode": "copy",
  "size": 3145728,
  "sha256": "<64 lowercase hex SHA-256 of exact archive bytes>",
  "file_count": 200,
  "owner_device": {
    "name": "desktop",
    "public_key": "<base64 SPKI DER of a P-256 public key>"
  },
  "recipient_hint": { "name": "phone" }
}
```

Replace placeholders with real values. `mode`: copy or swap. `size`: positive integer, at most 209715200 bytes. `file_count`: integer 0–1000000. Device names: trimmed, 1–40 characters. `recipient_hint` is optional and is not an authorization rule. Current server validation checks plausible SPKI formatting, not full cryptographic validity; clients must import and verify the actual key.

Returns 201: `channel_id`, `pairing_code` (six digits as a string), `expires_at`, `realtime_channel`, `owner_join_token`. Default TTL is 15 minutes; server configuration may change it, up to 120 minutes. Trust `expires_at`.

`POST /relay/channels/{channel_id}/join` needs no API key. Send the code only in the JSON body:

```json
{
  "pairing_code": "<six-digit code from owner>",
  "device": {
    "name": "phone",
    "public_key": "<recipient base64 SPKI public key>"
  }
}
```

Returns 200: `recipient_join_token`, `realtime_channel`, `channel_id`, `mode`, `size`, `sha256`, `file_count`, `visual_check`, `owner_device`. Optional join `mode` must match the offer; optional `owner_join_token` is used for self-join detection. Reusing the owner public key also fails.

Wrong code: 403 with `attempts_left`; the fifth wrong attempt destroys the channel and returns 410. Joining works exactly once; self-join, second join, or mode mismatch returns 409. Unknown or already deleted channels return 404. Expired channels are lazily deleted, so later requests may return 404 rather than 410.

For the following requests use your role's join token as the bearer credential, NOT the original API key:

- `GET /relay/channels/{channel_id}` — live state including role, status, mode, size, sha256, file_count, expires_at, visual_check, owner_device, recipient_device, progress.
- `POST /relay/channels/{channel_id}/status` — `{"status":"streaming","progress":{"n":1,"of":16}}`; status accepts streaming, completed, failed, aborted. Optional `reason` has at most 60 characters. Terminal states delete the row; the response contains `deleted:true`.
- `DELETE /relay/channels/{channel_id}` — abort and delete the channel.

Channel GET/DELETE/status reject URL keys. Missing/invalid role tokens return uniform 404. Lifecycle: open → paired → streaming → deletion on completed/failed/aborted; five wrong codes or expiry also delete metadata. Expiry cleanup runs on relay requests with an hourly backstop. Client buffers must be discarded separately by clients.

## Relay client contract

Use WebCrypto to generate an ECDSA P-256 identity. Public key format is base64 SPKI DER. Device ID is the first 16 lowercase hex characters of SHA-256 over decoded SPKI bytes.

```javascript
const keyPair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']
);
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
const publicKeyB64 = btoa(String.fromCharCode(...spki));
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', spki));
const deviceId = Array.from(digest, b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
```

Use the EXACT returned `realtime_channel`: `rly:{channel_id}:{random}`. Never reconstruct it from the channel ID or expose it publicly. The random suffix is the subscription capability. Realtime topics currently use public broadcast; HTTP join tokens authorize control-plane requests, not private realtime subscriptions. Role/direction checks and any cryptographic message verification are client responsibilities. Do not claim authenticated private subscriptions or end-to-end encryption.

The client contract uses broadcast event `relay`, with the following payloads:

```json
{"t":"chunk","i":0,"of":16,"b64":"<base64 chunk>"}
{"t":"ack","i":0}
{"t":"complete","sha256_ok":true}
{"t":"abort","reason":"integrity"}
```

Split exact UTF-8 archive bytes into 196608-byte chunks (192 KiB), yielding about 256 KiB base64 per chunk. Wait for ACK, retry missing chunks at most twice, then abort. Reassemble in memory and verify SHA-256 against the offer before importing anything. No file chunk is posted to the REST API. Broadcast is transient, not a durable delivery queue. Clients must subscribe successfully before streaming and handle duplicates/retransmits.

Visual check: first four uppercase hex characters of SHA-256 over the UTF-8 string `rv|{owner_public_key_b64}|{recipient_public_key_b64}|{size}`. Both screens must show the same check before proceeding. It is a visual pairing check, not encryption.

Copy changes no ownership. Swap, AFTER integrity verification, requires recipient `transfer_request`, explicit human acceptance on the owner's device, and owner `transfer_grant` containing `grant_sig`, `to_device_id`, `ts`, `channel_ref`. Sign with ECDSA P-256 / SHA-256 over exact UTF-8 bytes `bobswap-grant|{to_device_id}|{channel_id}|{visual_check}|{ts}`. Recipient verifies against the owner public key before any ownership changes. Store ownership/release receipts ONLY on the devices, never in the service. These client steps are a contract, not enforced by HTTP status updates; the two-tab harness has not yet verified them.

On cancellation, integrity failure, or a connection drop exceeding 20 seconds, clients must abort the channel and clear all buffers. The current control plane cannot detect a closed tab by itself. After verified reception, import locally (covered paths overwrite, other paths stay), set freshness from `createdAt`, and reload the recipient tab. Reload the owner only after a swap. There is no server OPFS import endpoint.

## Errors and operational limits

- 400: malformed input or archive; correct the first reported error.
- 401: invalid seed credential/scope or invalid signed link.
- 403: URL key on writes or incorrect pairing code.
- 404: missing seed/channel, deleted channel, or invalid channel role token.
- 409: version conflict, SHA-prefix mismatch, reserved version, or invalid pairing state.
- 410: channel burned or expired when detected before deletion.
- 413: archive too large.
- 429: rate limited; respect `Retry-After: 60` and back off with jitter.
- 500: storage/control-plane failure; retry carefully without changing archive bytes.
- 503: relay offers paused for maintenance.

Rate limits are best-effort in-memory per worker isolate, NOT globally shared quotas: 120 authenticated requests per key/minute, 240 per IP/minute, 20 failed auth attempts per IP/minute, 60 signed-link downloads per IP/minute, 10 failed relay joins per IP/minute. Five wrong codes burn an individual channel regardless of the IP bucket.

Previously tested: seed CRUD/auth/cache/link flows and about 5 MiB relayed between simulated clients. Not yet verified: near-200-MiB transfers, browser harness disconnect handling, and the end-to-end ownership swap confirmation/signature/receipt flow. Realtime delivery has no application-level encryption added by this service. Never log archives, codes, keys, role tokens, or capability topics.