Relay v2: writes and anonymous live

Task 12466 · Done · Coder · created · updated

## Context (BA Task 12447 → Architecture Task 12448 → Plan Task 12449) Original request (verbatim): "Regarding task <https://app.jaah.ai/tasks/12393> This is a repeat situation with the same user apparently behind a broken corporate firewall. He says that previous fix helped and his task list is no longer causing trouble, but he was now struggling with Projects edit. Go over all UI surfaces and ensure that the same code is reused everywhere - one choke point - that works identically and uses the approach and protocols that are now proven working. If there will be other similar issues we would only have to fix it in one place" Root cause (Architecture §0, AC-0): the hang was a WRITE — `PUT /api/projects/54` sat on a dead direct HTTP/2 connection (prod `activity_events` 1661992/3; every page read was relay-rescued, `client_events` 1335-1345). Writes are bare `fetch` with no relay/timeout (`apps/web/src/api.ts:68`); the relay is GET-only (`apps/api/app/routes/read_relay.py:13`). Operator decisions (verbatim, PRD §3 + Architecture §1): D2 "**Relay once + idempotency key.** Every write carries a unique key. When the direct connection looks dead, the write also goes over the WebSocket relay, and the server applies a given key at most once." D3 "**All four:** all `api()` reads + writes; the raw-fetch stragglers (`useVersionCheck`, live `config.ts`, live rewriter, S3 upload PUT); streams + terminal (EventSource `/api/stream`, terminal WebSocket); browser-fetched URLs (`apiHref`: img, iframe, downloads)." D4 "**Yes, also unify edit pages:** load via `useEditRecord`, save via `useSubmit`". A1 one epic, parallel where no dependency. A2 **At most once** (claim before run). A3 **All console writes, capped** (body ≤ 1 MiB, per-session write budget, key required). A4 **Public relay, live paths only** (cookieless socket, `GET /api/public/live/*`, per-IP budget). Full documents (read your section in full before building): the PRD and Architecture are the settled `prd` / `architecture` artifacts of Tasks 12447 / 12448 (read with `task_read` / `artifact_read`). Your section below is copied from them; the pinned seams are binding contracts other children build against — do not change them without updating the sibling that consumes them. ## Your scope: C2 — Request relay v2 Grow `apps/api/app/routes/read_relay.py` into the request relay on the SAME `/api/read-relay` path: frame contract v2 (method/body/idempotency-key), write caps and per-session write budget, anonymous live-only socket with per-IP budget. No idempotency logic in the relay — the C1 middleware judges relayed copies (it does not need C1 merged to build: test with a stub route). GET semantics unchanged. ### Architecture §3.2 (verbatim) ### 3.2 Request relay (server) — `apps/api/app/routes/read_relay.py` [child C2] The path stays `/api/read-relay`. The module docstring becomes the relay's single home for both directions. **Frame contract v2 (pinned; C3 and C4 build against it):** - In: `{"id", "method"?, "path", "headers", "body"?}`. `method` defaults to `GET`. `body` is base64. `headers` may now also carry `idempotency-key`, with `content-type` already allowed (`_MESSAGE_HEADERS`). - A non-GET frame without a valid `idempotency-key` → `{"id","error":"no_key"}`. A non-GET over 1 MiB decoded → `{"error":"too_large"}`. `MAX_FRAME_CHARS` becomes 1.5 M for an authenticated socket and stays 16 384 for an anonymous one. - Out: unchanged (`ack`, then `{status, headers, body}` or `{error}`). Add `idempotent-replayed` and `location` to `_ANSWER_HEADERS`. - The replay passes the method and body through `_scope`/`receive`. The idempotency middleware (C1) therefore judges the relayed copy exactly like the direct one: same ASGI app, same session, same middleware stack. **No idempotency logic lives in the relay.** - Write budget per session, separate from reads: `SESSION_WRITES_PER_SECOND = 2`, burst 20. `MAX_IN_FLIGHT` is shared. `READ_TIMEOUT_SECONDS` 30 s applies to writes too. - Refused prefixes are unchanged for writes (`/api/agent`, `/api/node-bus`, `/api/console-*`, `/api/stream`, `/api/auth`, the relay itself). `/api/auth/me` stays GET-only, and `PUT /api/auth/me` is refused (A3: auth surface stays edge-only). Uploads: `multipart/form-data` bodies ≤ 1 MiB are allowed. Larger ones go direct only (C3). - **Anonymous (live) socket (A4):** a handshake with no session cookie is accepted **only if** `origin_matches` passes. That socket answers only `GET` frames whose path starts `/api/public/live/`, and any other frame gets `error:"refused_path"`. Budget per client IP (`x-forwarded-for` first hop as nginx sets it): burst 30, 5/s, at most 4 sockets per IP. No cookie or identity headers are replayed (`cookie` is dropped from `_HANDSHAKE_HEADERS` for this socket). - Log line: `relay: admin=<id|anon> <METHOD> <path-without-query> -> <status>`. ### PRD assumptions that bind this child (verbatim) - AS4. **Intentional internals stay where they are, but are named.** The relay's own probe and socket, client telemetry and the live beacon (`keepalive` fire-and-forget) may keep their own `fetch`, as entries on the gate allowlist (AC-4), each with a one-line reason. Why: they are the choke point's own machinery, or must not be raced. ### Requirement constraints (PRD §7, verbatim) - Writes are never applied twice (D2, AC-2). Idempotency touches every mutating route, so it is a security-surface change (data integrity). Project instructions grant merge permission. The review lane still applies. - Act-as headers (`lib/actAs.ts`) and session headers (`lib/activeOrg.ts`) must ride relayed writes exactly as direct ones. A relayed write must be authorized as the same session. A relayed 401/403 never wins over the direct answer, the same rule as reads. - Single-home: the relay rules live in one module. `docs/standards/coding-standards.md` and the context policy apply. ### Risks that bind this child (Architecture §5, verbatim) 2. **WAF blind spot (A3).** Relayed writes skip the edge's body rules. Mitigations: session-only for writes, origin-checked handshake, 1 MiB cap, a 2/s write budget, the same refused prefixes, and every route's own authz. This is a security surface (data integrity plus a new public-anonymous socket), so it gets the full review lane. Merge permission is granted by project instructions. 6. **Anonymous socket abuse.** Bounded per IP, GET-only and live-prefix-only. It never replays a cookie. ### Acceptance criteria This child owns no PRD AC oracle (Architecture §4: contributes to AC-1, AC-2, AC-5); its done-check is its own server tests below. Credentials & reach: (Architecture §6, verbatim rows) | Oracle | Credential / identity / environment | Status | |---|---|---| | AC-2 API tests, AC-4/AC-8 gate | the worktree's `./dc` stack only | **verified**: needs nothing beyond the worktree | | AC-10 deploy run | `jaah-forge pr checks` / `ci log` (pre-authenticated forge) | **verified**: no credential to fetch | Out of scope: PRD §6 (verbatim) — - Do NOT add a hostname, port, certificate, or change cookies or auth to carry writes. The WebSocket relay carrier stays (decision of Task 11936). - Do NOT change the read relay's existing read semantics (race timing, probe, suspect and dropping memory) beyond what writes, streams and uploads need. - Do NOT change any edit page's visible UI: layout, fields, tabs, copy, URLs, routes (AC-9 quality). - Do NOT change React Query's global defaults (`apps/web/src/queryClient.ts`, `retry: false` for queries). - Do NOT change server route business logic. Server changes are limited to (a) idempotency for console writes and (b) the relay accepting writes under that key. - Do NOT touch `apps/site-widget`, `apps/docs`, `apps/jaahd`, `packages/` (AS9). - Do NOT refactor non-edit-page mutations (compliance, billing, playbook list, template cards) beyond what routing them through the choke point needs. - Do NOT add a feature flag (AS8). - Do NOT treat this as Nick-only: no per-user or per-org switch. - Do NOT change the relay's existing GET frame semantics, timings or budgets. - Do NOT add idempotency logic to the relay (C1 owns it); do NOT touch web code (C3/C4). Done means: - Server pytest (stub route) proves: non-GET without valid key → `no_key`; non-GET >1 MiB → `too_large`; writes carry method+body through `_scope`/`receive` with session headers identical to direct; write budget 2/s burst 20 enforced separately from reads; refused prefixes (incl. `PUT /api/auth/me`) still refused for writes; anonymous handshake accepted only on `origin_matches`, answers only `GET /api/public/live/*` (anything else `refused_path`), drops cookie, per-IP burst 30 / 5/s / ≤ 4 sockets; `idempotent-replayed` and `location` pass through `_ANSWER_HEADERS`; existing read-relay tests pass unchanged. - Module docstring is the single home of the relay contract for both directions. - `./dc check quick` green; PR merged via the merge lane; Jaah Deploy to dev green. This child owns no dev-browser oracle. Watch that deploy; if it fails, fix and merge again.

Outcome

Relay v2: /api/read-relay now carries keyed writes (≤1 MiB, 2/s burst 20) and an anonymous live-only socket (per-IP 30/5/s/4). 78 tests, quick gate green; PR #7682 merged 360b16d, Jaah Deploy green. Memory: none applied.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)