Route stragglers and live host
## 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: C4 — Stragglers + live host
Route `useVersionCheck`, live `config.ts`, live `apiMap.ts` rewriter and `putToStorage` (→ `net/storage.ts`) through `net/send` per §3.4, including the anonymous relay socket for the live host. If the live host cannot reach `/api/read-relay` on dev, add the location to the live host's nginx server block (Architecture §5 pre-mortem fallback).
### Architecture §3.4 (verbatim)
### 3.4 Stragglers and the live host [child C4]
- `hooks/useVersionCheck.ts`: reads `/version.json` through `net/send` with `{ raw: true }`, a static path that is not relayed but is timed and bounded at 10 s. A cut body or no answer resolves to "unknown". It never throws into the UI and never reports a newer version.
- `live/config.ts`: reads through `net/send`. A cut body throws, and the read retries once on the relay path (A4). It never resolves to a silent `null` config.
- `live/apiMap.ts` `liveRewriter.fetch`: returns `send`'s race for the rewritten `/api/public/live/*` URL with `credentials: 'omit'`. It no longer calls bare `fetch`. `net/relay.ts` opens the **anonymous** socket when the rewriter is installed: same path, and the browser sends no cookie because the live host has none.
- `api.ts` `putToStorage` moves to `net/storage.ts`. It runs with an AbortController bound of `max(30 s, size / 256 KiB·s)` and one fresh retry after an abort or network error. The URL is cross-origin S3, so a dead console connection does not share its pool. Otherwise it throws the existing `ApiError(0, …)` within the bound (AS5).
### PRD assumptions that bind this child (verbatim)
- AS5. **Uploads (S3 PUT, cross-origin) cannot ride our WebSocket as-is.** Assumed the requirement is behavioural: an upload on a dead connection recovers (a fresh-connection retry, or carried through the API) or fails visibly within the bound, never hangs. The carrier is the architect's choice.
- AS6. **The live host** (`src/live/`, public, cookieless) is in scope through D3. Assumed it gets the same unreadable-body rule and dead-connection recovery. If the read relay cannot serve public live paths, an equivalent fresh-connection retry is acceptable. The architect records which.
### 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)
6. **Anonymous socket abuse.** Bounded per IP, GET-only and live-prefix-only. It never replays a cookie.
### Acceptance criteria (PRD §5, verbatim)
**AC-5 — Stragglers through the choke point.**
- `useVersionCheck`: a cut body or dead connection never throws into the UI and never marks a false "new version".
- live host `config.ts`: a cut body throws/retries; never a silent `null` config.
- live host rewriter: live reads recover from a dead connection like console reads (AS6).
- S3 upload (`putToStorage`): on a dead connection the upload completes via a fresh attempt, or fails visibly within 30 s. Never an endless spinner (AS5).
- verify: dev Playwright per item. Hold the request → observe recovery or the bounded error. Live host: open a published Live Cast page on dev with its API reads held → the page fills.
Credentials & reach: (Architecture §6, verbatim rows)
| Oracle | Credential / identity / environment | Status |
|---|---|---|
| AC-1/3/5/6/7/9 dev Playwright | `me/jaah/debug/main/operator-login` (operator; its description says it works on dev and main) on `https://app.dev.jaah.ai` | **verified**: `credential_get` succeeded; `GET /version.json` → 200 from h2. `org/console/dev-login` also exists, but its base_url names a host that does not answer from h2, so the operator login is the one to use. |
| AC-5 live host | a published Live Cast project on dev | **verified**: dev `live_cast_projects` has 1 row, `enabled` and published |
| 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 touch streams/terminal/`apiHref` (C5), the gate (C7), or the core send/relay rules (C3) beyond consuming them.
Done means:
- AC-5 — oracle verbatim above (PRD §5 AC-5 `verify:` lines)
- Live reads RECOVER (the page fills) through the anonymous relay or the documented nginx fallback — a bounded error alone does not pass AC-5 for the live host. Healthy-path live reads and the version check keep today's latency.
- AC-8 on the live host: vitest shows a cut live body throws and live reads are raced, via the same `net/` code (no live-only copy of the rules).
- No bypass left in the files you own: grep the Architecture §3.6 patterns (`fetch(`, `new EventSource`, `new WebSocket`, `XMLHttpRequest`, `navigator.sendBeacon`, `apiHref(`, `.json()` on a Response) — zero hits outside `apps/web/src/net/`, `api.ts` and the §3.6 allowlist (clientTelemetry, live beacon, lazyRetry). Cite the grep output in the report.
- `./dc check quick` green; PR merged via the merge lane; Jaah Deploy to dev green. Dev oracles run on `https://app.dev.jaah.ai` (the PRD's `dev.jaah.ai` does not resolve from the nodes — Architecture §7), after your merge's Jaah Deploy to dev succeeds. Watch that deploy; if it fails, fix and merge again.
Activity
- Draft
- Ready