docs: comprehensive ARCHITECTURE, DEPLOYMENT, API, and THREAT_MODEL

ARCHITECTURE.md
- Full component map with ASCII diagram
- Complete session lifecycle (init + heartbeat + failure path)
- Cryptographic protocol spec (hash chain formula, canonical JSON)
- Stack machine instruction set table with stack effects
- Behavioral validation thresholds
- SQLite schema, threat model summary, module reference

DEPLOYMENT.md
- Build instructions (WASM + server + convenience script)
- native binary, systemd (with hardened sandbox notes), Docker
- nginx, Nginx Proxy Manager, and HAProxy reverse proxy configs
- Integration options (sidecar vs proxy-only)
- Full configuration table with all constants
- Observability (RUST_LOG levels), health check, security checklist

API.md
- Full /init and /hb request/response schemas with field tables
- Canonical signing payload specification
- Complete validation rules table (all 13 rejection conditions)
- Hash chain byte-level specification
- WASM exported function reference

THREAT_MODEL.md
- Four attacker profiles (script kiddie → sophisticated adversary)
- Eight attack vectors with mitigations (replay, forgery, hijack, DoS…)
- Explicit out-of-scope limitations
- Operational security notes (CORS, TLS, log level, SQLite)
This commit is contained in:
thakares committed 2026-05-09 18:04:13 +05:30
1 parent 851d3b4876
commit 4b27a342d1
4 files changed
+1017 -256

No files matched your search

+270 -240
View File
@@ -1,245 +1,253 @@
# ChronoSeal Architecture
# ChronoSeal — Architecture
## Overview
ChronoSeal is a stateless, cryptographic browser-attestation system. Its primary
goal is to make automated scraping and AI-driven crawling computationally
expensive and operationally complex, while remaining completely invisible to
real human users.
ChronoSeal is a stateless, cryptographic browser attestation framework. Its
purpose is to make automated clients (headless browsers, AI scrapers, API
harvesters) computationally expensive and operationally complex to operate,
while remaining completely invisible to real human users.
The design is inspired by the heartbeat model used in IoT firmware: a device
must continuously emit authenticated, chained proofs of liveness or the session
is invalidated. ChronoSeal applies this model to browser sessions.
The design is inspired by the heartbeat model used in embedded IoT firmware:
a device that stops sending signed, chained attestations is assumed to be
offline or compromised. ChronoSeal applies the same principle to browser
sessions.
---
## Core Principles
## Design Principles
| Principle | Implementation |
|---|---|
| Stateless per request | Only `session_id` is sent; all state lives server-side in SQLite |
| Cryptographic continuity | Blake3 hash chain — each heartbeat references and extends the previous |
| Secret isolation | Ed25519 private key generated inside WASM, never serialised to JS |
| Silent failure | All rejections return `{"status":"ok"}` — indistinguishable from success |
| Behavioral validation | Mouse entropy, speed, and pause patterns validated server-side |
| Frictionless to humans | No CAPTCHA, no visible UI, zero interaction required |
**Stateless per request.** The server carries no per-request state beyond what
is stored in SQLite keyed on `session_id`. Every HTTP request is independently
verifiable.
**Silent failure.** Validation failures never return an error status or an
error body. The server always responds `{"status":"ok"}` and simply omits
`next_salt`. The client degrades gracefully. Attackers cannot enumerate
validation rules by probing error responses.
**Private key isolation.** The Ed25519 signing key is generated inside the
WASM module and never serialised, never exposed to the JavaScript environment,
and never transmitted. It exists only in WASM linear memory for the lifetime
of the page.
**Layered validation.** A heartbeat must pass five independent checks: session
existence, expiry, signature, hash chain, and behavioral signals. Bypassing
one layer is not sufficient.
**Cost asymmetry.** Each heartbeat requires a real browser environment, mouse
activity, correct WASM execution, chain state synchronisation, and a valid
Ed25519 signature over a time-windowed payload. For an automated client, the
synchronisation burden alone makes scaled operation expensive.
---
## System Components
## Component Map
```
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌──────────────┐ ┌───────────────────────────────────┐ │
│ │ JavaScript │ │ WASM Module (antibot_wasm) │ │
│ │ │ │ │ │
│ │ heartbeat │◄──►│ crypto.rs — Ed25519 keypair │ │
│ │ entropy │ │ vm.rs — stack machine │ │
│ │ transport │ │ (private key never leaves here) │ │
│ └──────────────┘ └───────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ Server (Axum / Tokio) │
│ │
│ POST /init ──► session.rs ──► storage.rs (SQLite) │
│ POST /hb ──► session.rs │
│ ├── crypto.rs (Ed25519 verify) │
│ ├── trust.rs (mouse entropy) │
│ ├── fingerprint (browser signals) │
│ └── vm.rs (opcode generation) │
│ │
│ Background: cleanup.rs (session expiry + RL eviction) │
└─────────────────────────────────────────────────────────────┘
```
---
## Workspace Layout
```
chronoseal-rs/
│
├── shared/ Shared between server and WASM
│ ├── src/constants.rs All tunable parameters
│ ├── src/protocol.rs Request/response types (serde)
│ └── src/hashing.rs Blake3 hash-chain primitives
│
├── server/ Axum HTTP server
│ ├── src/main.rs Router, state init, background tasks
│ ├── src/routes/
│ │ ├── init.rs POST /init handler
│ │ └── heartbeat.rs POST /hb handler
│ ├── src/session.rs Session lifecycle: create + verify
│ ├── src/crypto.rs Ed25519 signature verification
│ ├── src/trust.rs Behavioral signal validation
│ ├── src/fingerprint.rs Browser fingerprint sanity checks
│ ├── src/vm.rs Random opcode program generator
│ ├── src/ratelimit.rs Token-bucket rate limiter
│ ├── src/cleanup.rs Background expiry + eviction loop
│ ├── src/storage.rs SQLite schema init + time utilities
│ └── src/middleware.rs Request logging
│
├── wasm/ Rust → WASM client module
│ ├── src/lib.rs Module declarations
│ ├── src/crypto.rs Keypair generation, signing, hashing
│ └── src/vm.rs Stack machine executor
│
└── frontend/ Vanilla JS glue
├── index.html Demo page
├── main.js Entry point
├── heartbeat.js Session init + heartbeat loop
├── entropy.js Mouse event collector
└── transport.js fetch() wrapper
┌─────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ entropy.js │ │ heartbeat.js │ │ transport.js│ │
│ │ │ │ │ │ │ │
│ │ mousemove │──►│ orchestrates │──►│ fetch POST │ │
│ │ event ring │ │ init + HB │ │ /init /hb │ │
│ └─────────────┘ └──────┬───────┘ └─────────────┘ │
│ │ │
│ ┌──────▼───────────────────────┐ │
│ │ WASM Module (antibot_wasm) │ │
│ │ │ │
│ │ crypto.rs vm.rs │ │
│ │ ├ generate_keypair() │ │
│ │ ├ sign_message() │ │
│ │ ├ compute_next_hash() │ │
│ │ └ run_program() │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ HTTPS
┌─────────────────────────▼───────────────────────────────┐
│ Server (Axum) │
│ │
│ routes/init.rs routes/heartbeat.rs │
│ │ │ │
│ └──────────┬───────────────┘ │
│ ▼ │
│ session.rs │
│ ├ create_session() │
│ └ verify_heartbeat() │
│ │ │
│ ┌──────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ crypto.rs trust.rs fingerprint.rs │
│ (sig verify) (mouse (aspect ratio, │
│ speed) DPR, HW conc.) │
│ │ │
│ ▼ │
│ shared::hashing (Blake3 hash chain) │
│ │ │
│ ▼ │
│ storage.rs (in-memory SQLite) │
│ │
│ ratelimit.rs cleanup.rs vm.rs middleware.rs │
└─────────────────────────────────────────────────────────┘
```
---
## Session Lifecycle
### 1. Initialisation (`POST /init`)
### 1. Initialisation — `POST /init`
```
Client Server
│ │
│ WASM: generate Ed25519 keypair │
│ private key → thread_local storage │
│ │
├─── { public_key: hex } ─────────────────►│
│ │ Generate session_id (32 bytes CSPRNG)
│ │ Generate salt₀ (16 bytes CSPRNG)
│ │ H₀ = Blake3(session_id ║ pub_key ║ salt₀)
│ │ Generate random VM program (8–16 opcodes)
│ │ Store: session_id, pub_key, salt₀, H₀
│ │
│◄── { session_id, salt, opcodes, H₀ } ────┤
│ │
│ Store: session_id, prevHash=H₀, │
│ currentSalt=salt₀, opcodes │
Client Server
│ │
│ generate Ed25519 keypair (in WASM) │
│ pub_key = verifying_key.to_bytes() │
│ │
├─── { public_key: hex(pub_key) } ──────►│
│ │ session_id = rand::random::<[u8;32]>()
│ │ salt₀ = rand::random::<[u8;16]>()
│ │ H(0) = Blake3(session_id║pub_key║salt₀)
│ │ opcodes = generate_random_program(8..=16)
│ │ INSERT INTO sessions …
│ │
│◄── { session_id, salt, opcodes_b64, │
│ initial_hash, expires_at } ───────┤
│ │
│ prevHash = initial_hash │
│ currentSalt = salt │
│ opcodesB64 = opcodes_b64 │
```
### 2. Heartbeat (`POST /hb`)
### 2. Heartbeat — `POST /hb`
Fired every 12–25 seconds with uniform random jitter.
```
Client Server
│ │
│ Collect mouse events since last HB │
│ Execute VM opcodes → stack state │
│ Build signable payload (sorted JSON): │
│ entropyData, fingerprint, prevHash, │
│ sessionId, stackState, timestamp │
│ Sign with Ed25519 private key │
│ │
├─── { session_id, prev_hash, timestamp, │
│ entropy_data, stack_state, │
│ fingerprint, signature } ─────────►│
│ │ Rate limit check
│ │ Look up session by session_id
│ │ Check expiry
│ │ Verify Ed25519 signature
│ │ Verify prev_hash == stored last_hash
│ │ Verify timestamp within ±30s
│ │ Validate mouse entropy (speed, pauses)
│ │ Validate fingerprint signals
│ │ Compute: H(n) = Blake3(salt ║ H(n-1) ║ …)
│ │ Generate next_salt
│ │ Update: last_hash=H(n), salt=next_salt
│ │
│◄── { status: "ok", next_salt } ──────────┤
│ │
│ sentSalt = currentSalt │
│ currentSalt = next_salt │
│ prevHash = Blake3(sentSalt ║ H(n-1) ║ … │ ← must mirror server computation
Client Server
│ │
│ stackState = run_program(opcodesB64) │
│ events = collectEntropy(lastTime) │
│ ts = Date.now() │
│ │
│ signable = { │
│ entropyData, fingerprint, │ ← keys sorted alphabetically
│ prevHash, sessionId, │
│ stackState, timestamp │
│ } │
│ sig = sign_message( │
│ JSON.stringify(signable, keys.sort))│
│ │
├─── { session_id, prev_hash, timestamp,│
│ entropy_data, stack_state, │
│ fingerprint, signature } ────────►│
│ │ 1. Rate limit check
│ │ 2. Lookup session, check expiry
│ │ 3. Verify Ed25519 signature
│ │ 4. Verify hash chain continuity
│ │ 5. Validate timestamp window ±30s
│ │ 6. Validate mouse behavior
│ │ 7. Validate fingerprint signals
│ │ 8. Compute H(n), rotate salt
│ │ 9. UPDATE sessions …
│ │
│◄── { status: "ok", next_salt } ────────┤
│ │
│ sentSalt = currentSalt ◄── captured BEFORE rotation
│ currentSalt = next_salt │
│ prevHash = compute_next_hash( │
│ prevHash, ts, entropy, │
│ stackState, sentSalt) │
```
### 3. Session Expiry
### 3. Failure Path
Sessions expire after 30 minutes of inactivity. A background task runs every
60 seconds to delete expired rows from SQLite and evict stale rate-limiter
entries from memory.
On any validation failure the server returns `{"status":"ok"}` with no
`next_salt`. The client logs a warning and continues scheduling heartbeats.
The chain is broken — subsequent heartbeats will also fail silently.
No error is surfaced to the page or its visitors.
---
## Hash Chain
## Cryptographic Protocol
The chain provides tamper-evidence: forging a valid heartbeat at position `n`
requires knowledge of `H(n-1)`, the current `salt`, and the Ed25519 private key.
None of these are available to an attacker who does not control the client WASM.
### Key Generation
```
Ed25519 keypair generated via ed25519-dalek + rand::thread_rng (OS-seeded)
Private key: stored in WASM thread_local, never leaves WASM memory
Public key: 32 bytes, hex-encoded, sent to server at init
```
### Hash Chain
```
H(0) = Blake3( session_id ║ pub_key ║ salt₀ )
H(n) = Blake3(
saltₙ₋₁ ← server-side only, rotated each heartbeat
║ H(n-1) ← stored server-side, sent by client
║ timestamp ← 8 bytes LE
║ Blake3(entropy) ← hash of mouse event JSON
║ Blake3(stack) ← hash of VM stack state JSON
saltₙ₋₁ ← server-side only, rotated each heartbeat
║ H(n-1) ← must match stored last_hash
║ timestamp_u64_le
║ Blake3( JSON(entropy_data) )
║ Blake3( JSON(stack_state) )
)
```
Salt rotation means that even a full replay of a captured heartbeat is invalid
on the next cycle — the salt has changed.
Salt rotation means an attacker who intercepts a heartbeat cannot compute
future chain links without also intercepting every subsequent server response.
### Canonical Signing Payload
The signed message is a JSON object with top-level keys sorted alphabetically,
serialised with no extra whitespace:
```json
{
"entropyData": { "events": [{"t":…,"x":…,"y":…}] },
"fingerprint": { "aspectRatio":"…","devicePixelRatio":"…","hardwareConcurrency":… },
"prevHash": "hex…",
"sessionId": "hex…",
"stackState": { "ip":…,"stack":[…] },
"timestamp": 1234567890123
}
```
The server reconstructs this using `std::collections::BTreeMap` (alphabetical
key order) before calling `VerifyingKey::verify_strict`. Any field mismatch,
key order difference, or whitespace difference causes a signature failure.
### Hashing Algorithm
Blake3 is used throughout: hash chain links, entropy data digest, stack state
digest, and the VM HASH opcode. Blake3 is chosen for speed in WASM,
resistance to length-extension attacks, and a clean Rust API.
---
## Stack Machine
The server generates a random program on session init. The client executes it
on every heartbeat. The resulting stack state is included in the signed payload
and the hash chain, making each heartbeat structurally unique.
on every heartbeat and includes the resulting `StackState { stack, ip }` in
the signed payload. This ensures each heartbeat carries unique, verifiable
computation without additional round-trips.
### Instruction Set
| Opcode | Mnemonic | Stack effect | Description |
|--------|----------|-------------|-------------|
| `0x00` + 4 bytes | PUSH | `→ val` | Push 32-bit LE literal |
| `0x01` | ADD | `a b → a+b` | Wrapping addition |
| `0x02` | SUB | `a b → a-b` | Wrapping subtraction |
| `0x03` | MUL | `a b → a*b` | Wrapping multiplication |
| `0x04` | XOR | `a b → a^b` | Bitwise XOR |
| `0x05` | AND | `a b → a&b` | Bitwise AND |
| `0x06` | OR | `a b → a\|b` | Bitwise OR |
| `0x07` | ROT | `a b → a.rotate_left(b%32)` | Bit rotation |
| `0x08` | NOT | `a → !a` | Bitwise NOT (unary) |
| `0x09` | HASH | `[…] → u32` | Blake3 of full stack → single u32 |
| Opcode | Mnemonic | Operand | Stack effect | Description |
|--------|----------|---------------|--------------|-------------|
| `0x00` | PUSH | u32 (4B LE) | +1 | Push literal |
| `0x01` | ADD | — | −1 | `a + b` wrapping |
| `0x02` | SUB | — | −1 | `a - b` wrapping |
| `0x03` | MUL | — | −1 | `a * b` wrapping |
| `0x04` | XOR | — | −1 | `a ^ b` |
| `0x05` | AND | — | −1 | `a & b` |
| `0x06` | OR | — | −1 | `a \| b` |
| `0x07` | ROT | — | −1 | `a.rotate_left(b % 32)` |
| `0x08` | NOT | — | 0 | `!a` (unary) |
| `0x09` | HASH | — | -(depth-1) | Blake3 of all stack items → single u32 |
### Program Generation
The server generates programs with a depth-tracking algorithm that guarantees
at least 2 operands before any binary op is emitted. Programs are 8–16
instructions. The client VM halts gracefully on underflow — invalid opcodes
produce a partial stack state that still participates in the hash chain.
---
## Cryptographic Primitives
| Primitive | Usage |
|---|---|
| **Ed25519** (ed25519-dalek v2) | Client keypair; signs each heartbeat payload |
| **Blake3** | Hash chain; entropy hashing; stack hashing |
| **CSPRNG** (rand / getrandom) | Session ID, salt, VM opcodes |
### Canonical Signing Format
The signed payload is a JSON object with keys sorted **alphabetically**,
matching `JSON.stringify(obj, Object.keys(obj).sort())` on the client and
`BTreeMap` serialisation on the server:
```json
{
"entropyData": { "events": [ { "x": 123.0, "y": 456.0, "t": 1234.5 } ] },
"fingerprint": { "aspectRatio": "1.7777777778", "devicePixelRatio": "2", "hardwareConcurrency": 8 },
"prevHash": "a3f2…",
"sessionId": "9c1b…",
"stackState": { "stack": [2147483648], "ip": 12 },
"timestamp": 1746700000000
}
```
The generator ensures ≥ 2 items on the stack before any binary opcode.
NOT (0x08) does not change depth. HASH resets depth to 1.
---
@@ -247,79 +255,101 @@ matching `JSON.stringify(obj, Object.keys(obj).sort())` on the client and
### Mouse Entropy
| Check | Threshold | Rationale |
|---|---|---|
| Minimum events | ≥ 3 | Single-point or no-movement signals headless |
| Total distance | ≥ 10 px | Rules out stationary cursors |
| Average speed | ≤ 2.0 px/ms | Rules out programmatic linear sweeps |
| Pause count | ≥ 1 | Human movement includes micro-stops |
Every heartbeat includes the mouse events collected since the previous
heartbeat. Server checks:
Speed is computed as `total_distance / total_elapsed_ms` over the event window.
| Check | Threshold |
|---|---|
| Minimum event count | ≥ 3 |
| Minimum cumulative distance | ≥ 10 px |
| Maximum average speed | ≤ 2.0 px/ms (distance / elapsed ms) |
| Minimum pause count | ≥ 1 (movement < 0.2 px over > 50 ms) |
### Fingerprint Signals
### Browser Fingerprint
| Signal | Valid range | Rationale |
|---|---|---|
| Aspect ratio | 0.5 – 3.0 | Headless defaults are often 1:1 or extreme values |
| Device pixel ratio | 0.0 – 5.0 | Zero DPR is impossible on real hardware |
| Hardware concurrency | ≥ 1 | Zero is impossible; headless may report 0 |
| Signal | Valid range |
|---|---|
| `aspectRatio` (width / height) | 0.5 – 3.0 |
| `devicePixelRatio` | 0 < dpr ≤ 5.0 |
| `hardwareConcurrency` | ≥ 1 |
---
## Rate Limiting
Per-session token bucket: 5 requests per 10-second window. Rate limit state
is held in a `HashMap<String, (u32, Instant)>` in process memory. The cleanup
loop calls `evict_stale()` every 60 seconds to prevent unbounded growth from
unique session IDs.
Token bucket per `session_id`: 5 requests / 10-second window.
Stale entries evicted every 60 seconds by the cleanup task.
Rate-limited responses are indistinguishable from validation failures.
---
## Storage
ChronoSeal uses an **in-memory SQLite** database. All session state is lost on
server restart. Clients transparently re-initialise on the next page load.
## SQLite Schema
```sql
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
public_key BLOB NOT NULL,
salt BLOB NOT NULL,
last_hash BLOB NOT NULL,
public_key BLOB NOT NULL, -- 32-byte Ed25519 verifying key
salt BLOB NOT NULL, -- 16-byte current salt
last_hash BLOB NOT NULL, -- 32-byte Blake3 chain head
chain_length INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
expires_at INTEGER NOT NULL
created_at INTEGER NOT NULL, -- Unix ms
last_seen INTEGER NOT NULL, -- Unix ms
expires_at INTEGER NOT NULL -- Unix ms
);
```
For persistence across restarts, replace `Connection::open_in_memory()` in
`server/src/storage.rs` with `Connection::open("/var/lib/chronoseal/sessions.db")`.
In-memory SQLite — all sessions lost on server restart by design.
Clients re-initialise transparently on the next page load.
---
## Threat Model
### What ChronoSeal raises the cost of
### In Scope
- **Playwright / Puppeteer Stealth** — mouse entropy validation detects absent
or synthetic movement
- **Replay attacks** — hash chain + salt rotation invalidates captured payloads
on the next cycle
- **Signature forgery** — Ed25519 private key is generated inside WASM and
never exposed to the JS context
- **Clock manipulation** — server enforces ±30s timestamp window
- **Credential sharing** — keypair is generated fresh on every page load;
session is bound to that keypair
- **Traffic analysis** — all responses return `{"status":"ok"}`; rejections
are indistinguishable from successes
| Threat | Mitigation |
|---|---|
| Playwright / Puppeteer / Selenium | Mouse entropy + behavioral validation |
| Puppeteer Stealth, undetected-chromedriver | Signature over VM execution state |
| Heartbeat replay | Hash chain + ±30s timestamp window |
| Signature forgery | Private key isolated in WASM memory |
| Parallel session sharing | Each session bound to a unique keypair |
| Brute-forced session IDs | 256-bit random entropy |
| Flooding with fake session IDs | Rate limiter + periodic HashMap eviction |
| Traffic analysis | Uniform `{"status":"ok"}` on all failure paths |
### What ChronoSeal does not prevent
### Out of Scope
- An attacker running a real browser with a real mouse on real hardware
- A sufficiently motivated adversary who reverse-engineers the WASM, replicates
the hash chain, and synthesises mouse events
- Server-side data exfiltration once a valid session is established
| Threat | Reason |
|---|---|
| Real browser with real human input | Indistinguishable from a legitimate user |
| WASM reverse engineering | Obfuscation is not a security primitive |
| Server-side compromise | Outside the scope of client attestation |
ChronoSeal is a **cost-raising** mechanism, not an impenetrable barrier.
The goal is to make scraping at scale impractical, not impossible.
ChronoSeal raises cost and complexity of automated access. It is not a
cryptographic proof of humanity and does not claim to be.
---
## Module Reference
| Path | Purpose |
|---|---|
| `shared/src/protocol.rs` | Shared types: `InitRequest`, `HeartbeatRequest`, `StackState`, … |
| `shared/src/hashing.rs` | `initial_hash`, `next_chain_hash`, `hash_stack` |
| `shared/src/constants.rs` | All tunable parameters |
| `server/src/routes/init.rs` | `POST /init` handler |
| `server/src/routes/heartbeat.rs` | `POST /hb` handler |
| `server/src/session.rs` | `create_session`, `verify_heartbeat` |
| `server/src/crypto.rs` | `verify_signature` — BTreeMap canonical JSON |
| `server/src/trust.rs` | `validate_mouse` — speed, distance, pauses |
| `server/src/fingerprint.rs` | `validate` — aspect ratio, DPR, HW concurrency |
| `server/src/vm.rs` | `generate_random_program` |
| `server/src/ratelimit.rs` | `RateLimiter::check`, `evict_stale` |
| `server/src/cleanup.rs` | Background loop: expire sessions + evict rate limiter |
| `server/src/storage.rs` | SQLite init, `current_time_ms` |
| `wasm/src/crypto.rs` | `generate_keypair`, `sign_message`, `compute_next_hash` |
| `wasm/src/vm.rs` | `run_program` — stack machine executor |
| `frontend/heartbeat.js` | Session init, heartbeat loop, chain advancement |
| `frontend/entropy.js` | Mouse event ring buffer, `collectEntropy` |
| `frontend/transport.js` | `sendRequest` fetch wrapper |