Add game data capture vertical slice

This commit is contained in:
jb
2026-07-31 14:34:24 -04:00
parent 5859f84034
commit fee4dc3a78
75 changed files with 12654 additions and 5 deletions
@@ -0,0 +1,567 @@
"""FastAPI factory for the local capture-and-labeling boundary.
Construction only retains injected ports and process-local session state. It does
not bind a listener, initialize runtime paths, open SQLite, arm capture, read a
clipboard, or create a child process.
"""
from __future__ import annotations
import json
import re
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal, Protocol, TypeVar
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
from fastapi.templating import Jinja2Templates
from starlette.staticfiles import StaticFiles
from game_data_capture.config import ServerConfig
from game_data_capture.domain.state_machines import CaptureState
from game_data_capture.domain.validation import ContractValidationError, validate_classification
from game_data_capture.web.security import (
AuthenticatedSession,
AuthenticationError,
BodyLimitExceeded,
ClientDisconnected,
LocalAuthority,
LocalRequestLimitsMiddleware,
RequestLimits,
SecurityError,
SessionStore,
response_security_headers,
set_session_cookie,
single_header,
validate_origin,
)
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
_STATE_VALUES = frozenset(state.value for state in CaptureState)
_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
_OUTCOME_VALUES = frozenset({"updated", "not_found", "conflict", "retained"})
_PortResult = TypeVar("_PortResult")
class ServiceUnavailable(RuntimeError):
"""An injected port cannot safely complete this local request."""
@dataclass(frozen=True, slots=True)
class WebConfig:
"""Static local service settings supplied by runtime integration."""
server: ServerConfig
request_limits: RequestLimits = field(default_factory=RequestLimits)
max_capture_items: int = 64
max_image_bytes: int = 64 * 1024 * 1024
bootstrap_ttl_seconds: int = 600
session_ttl_seconds: int = 900
def __post_init__(self) -> None:
for value, maximum in (
(self.max_capture_items, 256),
(self.max_image_bytes, 64 * 1024 * 1024),
(self.bootstrap_ttl_seconds, 3600),
(self.session_ttl_seconds, 3600),
):
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
raise ValueError("web service configuration is invalid")
@property
def authority(self) -> LocalAuthority:
return LocalAuthority(self.server.host, self.server.port)
@dataclass(frozen=True, slots=True)
class CaptureSessionStatus:
armed: bool
queue_depth: int
queue_capacity: int
@dataclass(frozen=True, slots=True)
class CaptureRecord:
"""A safe browser projection; ports must never place paths or OCR text here."""
capture_id: str
state: str
received_at: str
classification: Mapping[str, object] | None
@dataclass(frozen=True, slots=True)
class MediaDescriptor:
"""An opaque capture-media stream supplied by a corpus port, never a path."""
media_type: str
byte_size: int
chunks: Iterable[bytes]
MutationOutcome = Literal["updated", "not_found", "conflict", "retained"]
ControlOutcome = Literal["started", "already_armed", "stopped", "already_stopped", "conflict"]
class CaptureControlPort(Protocol):
"""Narrow control boundary implemented by later runtime integration."""
def status(self) -> CaptureSessionStatus: ...
def start_session(self) -> ControlOutcome: ...
def stop_session(self) -> ControlOutcome: ...
class CaptureCorpusPort(Protocol):
"""Narrow durable corpus boundary; routes never access SQLite or paths."""
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]: ...
def get_capture(self, capture_id: str) -> CaptureRecord | None: ...
def open_media(self, capture_id: str) -> MediaDescriptor | None: ...
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome: ...
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome: ...
def delete_with_retention(self, capture_id: str) -> MutationOutcome: ...
def _response(payload: Mapping[str, object], *, status_code: int = 200) -> JSONResponse:
return JSONResponse(
payload,
status_code=status_code,
headers={"Cache-Control": "no-store", **response_security_headers()},
)
def _error(status_code: int, code: str) -> JSONResponse:
return _response({"error": code}, status_code=status_code)
def _call_port(operation: Callable[[], _PortResult]) -> _PortResult:
"""Translate every injected-port failure without retaining its raw message."""
try:
return operation()
except Exception as error:
raise ServiceUnavailable("local service operation failed") from error
def _valid_capture_id(capture_id: str) -> bool:
return bool(_CAPTURE_ID.fullmatch(capture_id))
def _status_projection(status: CaptureSessionStatus) -> dict[str, object]:
if (
not isinstance(status.armed, bool)
or isinstance(status.queue_depth, bool)
or isinstance(status.queue_capacity, bool)
or not isinstance(status.queue_depth, int)
or not isinstance(status.queue_capacity, int)
or not 0 <= status.queue_depth <= status.queue_capacity <= 256
):
raise ServiceUnavailable("capture control returned an invalid status")
return {
"armed": status.armed,
"queue_depth": status.queue_depth,
"queue_capacity": status.queue_capacity,
}
def _classification_projection(classification: Mapping[str, object]) -> dict[str, object]:
# Reconstruct only the frozen contract keys before returning port data to a browser.
if not isinstance(classification, Mapping) or set(classification) != {
"family",
"view",
"gear_slot",
"record_types",
"confidence",
"abstention_reason",
}:
raise ServiceUnavailable("corpus returned an invalid classification")
payload = dict(classification)
try:
validate_classification(payload)
except ContractValidationError as error:
raise ServiceUnavailable("corpus returned an invalid classification") from error
family = payload["family"]
view = payload["view"]
gear_slot = payload["gear_slot"]
record_types = payload["record_types"]
confidence = payload["confidence"]
reason = payload["abstention_reason"]
if (
not isinstance(family, str)
or not isinstance(view, str)
or (gear_slot is not None and not isinstance(gear_slot, str))
or not isinstance(record_types, list)
or any(not isinstance(record_type, str) for record_type in record_types)
or isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or (reason is not None and not isinstance(reason, str))
):
raise ServiceUnavailable("corpus returned an invalid classification")
return {
"family": family,
"view": view,
"gear_slot": gear_slot,
"record_types": record_types,
"confidence": confidence,
"abstention_reason": reason,
}
def _record_projection(record: CaptureRecord) -> dict[str, object]:
if (
not isinstance(record.capture_id, str)
or not _valid_capture_id(record.capture_id)
or not isinstance(record.state, str)
or record.state not in _STATE_VALUES
or not isinstance(record.received_at, str)
or not 1 <= len(record.received_at) <= 40
or any(character in record.received_at for character in "\r\n")
):
raise ServiceUnavailable("corpus returned an invalid capture")
payload: dict[str, object] = {
"capture_id": record.capture_id,
"state": record.state,
"received_at": record.received_at,
"classification": None,
}
if record.classification is not None:
payload["classification"] = _classification_projection(record.classification)
return payload
def _json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def _reject_json_constant(value: str) -> object:
raise ValueError(f"unsupported JSON constant {value}")
async def _read_json(request: Request, *, maximum: int) -> dict[str, object]:
content_type = single_header(request, b"content-type", required=True)
if content_type is None:
raise SecurityError("unsupported request content type")
try:
media_type = content_type.decode("ascii")
except UnicodeDecodeError as error:
raise SecurityError("unsupported request content type") from error
if media_type.split(";", 1)[0].strip().lower() != "application/json":
raise SecurityError("unsupported request content type")
body = await request.body()
if not 2 <= len(body) <= maximum:
raise SecurityError("request JSON size is invalid")
try:
value = json.loads(
body.decode("utf-8"),
object_pairs_hook=_json_pairs,
parse_constant=_reject_json_constant,
)
except ValueError as error:
raise SecurityError("request JSON is invalid") from error
if not isinstance(value, dict):
raise SecurityError("request JSON must be an object")
return value
def _bootstrap_payload(value: Mapping[str, object]) -> str:
if set(value) != {"token"}:
raise SecurityError("bootstrap payload is invalid")
token = value.get("token")
if not isinstance(token, str) or not 32 <= len(token) <= 128:
raise SecurityError("bootstrap payload is invalid")
return token
def _confirmation_payload(value: Mapping[str, object], confirmation: str) -> None:
if set(value) != {"confirmation"} or value.get("confirmation") != confirmation:
raise SecurityError("confirmation payload is invalid")
def _label_payload(value: Mapping[str, object]) -> dict[str, object]:
expected = {
"family",
"view",
"gear_slot",
"record_types",
"confidence",
"abstention_reason",
}
if set(value) != expected:
raise SecurityError("label payload is invalid")
family = value.get("family")
view = value.get("view")
record_types = value.get("record_types")
reason = value.get("abstention_reason")
if (
not isinstance(family, str)
or not 1 <= len(family) <= 32
or not isinstance(view, str)
or not 1 <= len(view) <= 32
or not isinstance(record_types, list)
or not 0 <= len(record_types) <= 8
or any(
not isinstance(record_type, str) or not 1 <= len(record_type) <= 64
for record_type in record_types
)
or (reason is not None and (not isinstance(reason, str) or not 1 <= len(reason) <= 64))
):
raise SecurityError("label payload is invalid")
payload = dict(value)
try:
validate_classification(payload)
except ContractValidationError as error:
raise SecurityError("label payload is invalid") from error
return payload
def _outcome_response(outcome: MutationOutcome) -> tuple[int, str]:
if outcome not in _OUTCOME_VALUES:
raise ServiceUnavailable("corpus returned an invalid mutation outcome")
if outcome == "updated":
return 200, "updated"
if outcome == "not_found":
return 404, "not_found"
if outcome == "conflict":
return 409, "conflict"
return 409, "retained"
def _media_bytes(media: MediaDescriptor, maximum: int) -> tuple[str, bytes]:
"""Materialize and validate bounded port media before response headers exist."""
try:
if (
media.media_type not in _IMAGE_MEDIA_TYPES
or isinstance(media.byte_size, bool)
or not isinstance(media.byte_size, int)
or not 1 <= media.byte_size <= maximum
):
raise ServiceUnavailable("corpus returned unsafe media")
materialized = bytearray()
for chunk in media.chunks:
if not isinstance(chunk, bytes) or not chunk:
raise ServiceUnavailable("corpus returned unsafe media")
if (
len(materialized) + len(chunk) > media.byte_size
or len(materialized) + len(chunk) > maximum
):
raise ServiceUnavailable("corpus returned unsafe media")
materialized.extend(chunk)
if len(materialized) != media.byte_size:
raise ServiceUnavailable("corpus returned unsafe media")
return media.media_type, bytes(materialized)
except ServiceUnavailable:
raise
except Exception as error:
raise ServiceUnavailable("corpus media stream failed") from error
def _session_response(
payload: dict[str, object],
session_store: SessionStore,
session: AuthenticatedSession,
config: WebConfig,
) -> JSONResponse:
rotated = session_store.rotate(session)
payload["csrf_token"] = rotated.csrf_token
response = _response(payload)
set_session_cookie(response, rotated, max_age=config.session_ttl_seconds)
return response
def create_app(
*,
config: WebConfig,
capture_control: CaptureControlPort,
corpus: CaptureCorpusPort,
bootstrap_token: str,
now: Callable[[], float] = time.monotonic,
) -> FastAPI:
"""Create an unbound app using only narrow, caller-injected local ports."""
authority = config.authority
sessions = SessionStore(
bootstrap_token,
now=now,
bootstrap_ttl_seconds=config.bootstrap_ttl_seconds,
session_ttl_seconds=config.session_ttl_seconds,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
app.add_middleware(
LocalRequestLimitsMiddleware, authority=authority, limits=config.request_limits
)
app.mount(
"/static",
StaticFiles(
directory=Path(__file__).with_name("static"),
check_dir=False,
follow_symlink=False,
),
name="static",
)
templates = Jinja2Templates(directory=str(Path(__file__).with_name("templates")))
@app.exception_handler(AuthenticationError)
async def authentication_error(_: Request, __: AuthenticationError) -> JSONResponse:
return _error(401, "authentication_required")
@app.exception_handler(BodyLimitExceeded)
async def body_limit_error(_: Request, __: BodyLimitExceeded) -> JSONResponse:
return _error(413, "request_too_large")
@app.exception_handler(ClientDisconnected)
async def disconnect_error(_: Request, __: ClientDisconnected) -> JSONResponse:
return _error(400, "request_rejected")
@app.exception_handler(SecurityError)
async def security_error(_: Request, __: SecurityError) -> JSONResponse:
return _error(403, "request_forbidden")
@app.exception_handler(ServiceUnavailable)
async def service_error(_: Request, __: ServiceUnavailable) -> JSONResponse:
return _error(503, "service_unavailable")
@app.exception_handler(Exception)
async def unexpected_error(_: Request, __: Exception) -> JSONResponse:
return _error(503, "service_unavailable")
@app.get("/health")
async def health() -> JSONResponse:
return _response({"status": "ok"})
@app.get("/", response_class=Response)
async def review_page(request: Request) -> Response:
return templates.TemplateResponse(request=request, name="index.html", context={})
@app.post("/api/bootstrap")
async def bootstrap(request: Request) -> JSONResponse:
validate_origin(request, authority)
session = sessions.bootstrap(_bootstrap_payload(await _read_json(request, maximum=256)))
response = _response({"csrf_token": session.csrf_token})
set_session_cookie(response, session, max_age=config.session_ttl_seconds)
return response
@app.get("/api/session")
async def session_status(request: Request) -> JSONResponse:
session = sessions.authenticate(request)
payload = _status_projection(_call_port(capture_control.status))
payload["csrf_token"] = session.csrf_token
return _response(payload)
@app.post("/api/session/start")
async def start_session(request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
_confirmation_payload(await _read_json(request, maximum=64), "START")
outcome = _call_port(capture_control.start_session)
if outcome not in {"started", "already_armed", "conflict"}:
raise ServiceUnavailable("capture control returned an invalid start outcome")
if outcome == "conflict":
return _error(409, "conflict")
payload = _status_projection(_call_port(capture_control.status))
payload["result"] = outcome
return _session_response(payload, sessions, session, config)
@app.post("/api/session/stop")
async def stop_session(request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
_confirmation_payload(await _read_json(request, maximum=64), "STOP")
outcome = _call_port(capture_control.stop_session)
if outcome not in {"stopped", "already_stopped", "conflict"}:
raise ServiceUnavailable("capture control returned an invalid stop outcome")
if outcome == "conflict":
return _error(409, "conflict")
payload = _status_projection(_call_port(capture_control.status))
payload["result"] = outcome
return _session_response(payload, sessions, session, config)
@app.get("/api/captures")
async def list_captures(request: Request) -> JSONResponse:
sessions.authenticate(request)
def list_projection() -> list[dict[str, object]]:
records = corpus.list_captures(limit=config.max_capture_items)
if len(records) > config.max_capture_items:
raise ValueError("corpus returned too many captures")
return [_record_projection(record) for record in records]
return _response({"captures": _call_port(list_projection)})
@app.get("/api/captures/{capture_id}")
async def get_capture(capture_id: str, request: Request) -> JSONResponse:
sessions.authenticate(request)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
record = _call_port(lambda: corpus.get_capture(capture_id))
if record is None:
return _error(404, "not_found")
return _response({"capture": _call_port(lambda: _record_projection(record))})
@app.get("/api/captures/{capture_id}/media")
async def capture_media(capture_id: str, request: Request) -> Response:
sessions.authenticate(request)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
media = _call_port(lambda: corpus.open_media(capture_id))
if media is None:
return _error(404, "not_found")
media_type, content = _call_port(lambda: _media_bytes(media, config.max_image_bytes))
return Response(
content=content,
media_type=media_type,
headers={
"Cache-Control": "private, no-store",
},
)
@app.post("/api/captures/{capture_id}/label")
async def label_capture(capture_id: str, request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
classification = _label_payload(await _read_json(request, maximum=4096))
outcome = _call_port(lambda: corpus.label(capture_id, classification))
status, result = _outcome_response(outcome)
if status != 200:
return _error(status, result)
return _session_response({"result": result}, sessions, session, config)
@app.post("/api/captures/{capture_id}/reject")
async def reject_capture(capture_id: str, request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
_confirmation_payload(await _read_json(request, maximum=64), "REJECT")
outcome = _call_port(lambda: corpus.reject_preserving_evidence(capture_id))
status, result = _outcome_response(outcome)
if status != 200:
return _error(status, result)
return _session_response({"result": result}, sessions, session, config)
@app.post("/api/captures/{capture_id}/delete")
async def delete_capture(capture_id: str, request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
_confirmation_payload(await _read_json(request, maximum=64), "DELETE")
outcome = _call_port(lambda: corpus.delete_with_retention(capture_id))
status, result = _outcome_response(outcome)
if status != 200:
return _error(status, result)
return _session_response({"result": result}, sessions, session, config)
return app
@@ -0,0 +1,352 @@
"""Local-only request guards and process-local session state.
This module intentionally has no runtime, storage, filesystem, or server imports.
The runtime integration layer owns listener construction and must disable Uvicorn
proxy headers so forwarded request headers never affect this boundary.
"""
from __future__ import annotations
import ipaddress
import json
import re
import secrets
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from fastapi import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
_SECRET = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
_COOKIE_NAME = "capture_session"
_RESPONSE_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = (
(
b"content-security-policy",
b"default-src 'self'; base-uri 'none'; object-src 'none'; frame-src 'none'; "
b"frame-ancestors 'none'; "
b"script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; "
b"media-src 'self'; connect-src 'self'; form-action 'self'",
),
(b"referrer-policy", b"no-referrer"),
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
)
def response_security_headers() -> dict[str, str]:
"""Return response headers for errors generated outside the ASGI wrapper."""
return {
name.decode("ascii"): value.decode("ascii") for name, value in _RESPONSE_SECURITY_HEADERS
}
class SecurityError(ValueError):
"""A request failed a security boundary without exposing its input."""
class AuthenticationError(SecurityError):
"""The process-local authenticated session is absent or expired."""
class BodyLimitExceeded(SecurityError):
"""The declared or streamed request body exceeded the configured limit."""
class ClientDisconnected(SecurityError):
"""The client disconnected before a route obtained a complete request body."""
@dataclass(frozen=True, slots=True)
class LocalAuthority:
"""The single exact loopback authority accepted by the HTTP boundary."""
host: str
port: int
def __post_init__(self) -> None:
try:
address = ipaddress.ip_address(self.host)
except ValueError as error:
raise ValueError("web host must be a loopback IP literal") from error
if not address.is_loopback or not 1024 <= self.port <= 65535:
raise ValueError("web authority is invalid")
@property
def authority(self) -> str:
return f"[{self.host}]:{self.port}" if ":" in self.host else f"{self.host}:{self.port}"
@property
def origin(self) -> str:
return f"http://{self.authority}"
@dataclass(frozen=True, slots=True)
class RequestLimits:
max_header_bytes: int = 16 * 1024
max_headers: int = 64
max_body_bytes: int = 16 * 1024
def __post_init__(self) -> None:
for value in (self.max_header_bytes, self.max_headers, self.max_body_bytes):
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError("request limits must be positive integers")
def new_bootstrap_token() -> str:
"""Create a fragment-safe, one-time token for the local bootstrap mutation."""
return secrets.token_urlsafe(32)
def _header_values(scope: Scope, name: bytes) -> list[bytes]:
return [value for key, value in scope.get("headers", []) if key.lower() == name]
def _single_header(scope: Scope, name: bytes, *, required: bool) -> bytes | None:
values = _header_values(scope, name)
if len(values) != 1:
if not values and not required:
return None
raise SecurityError("ambiguous request header")
return values[0]
def single_header(request: Request, name: bytes, *, required: bool) -> bytes | None:
"""Return one unambiguous request header without trusting framework merging."""
return _single_header(request.scope, name, required=required)
def _ascii(value: bytes) -> str:
try:
return value.decode("ascii")
except UnicodeDecodeError as error:
raise SecurityError("invalid request header") from error
def validate_host(scope: Scope, authority: LocalAuthority) -> None:
value = _single_header(scope, b"host", required=True)
assert value is not None
if not secrets.compare_digest(_ascii(value), authority.authority):
raise SecurityError("unexpected request authority")
def validate_origin(request: Request, authority: LocalAuthority) -> None:
value = _single_header(request.scope, b"origin", required=True)
assert value is not None
if not secrets.compare_digest(_ascii(value), authority.origin):
raise SecurityError("unexpected request origin")
def _validate_content_length(scope: Scope, maximum: int) -> None:
value = _single_header(scope, b"content-length", required=False)
if value is None:
return
raw = _ascii(value)
if not raw.isascii() or not raw.isdecimal():
raise SecurityError("invalid content length")
if int(raw) > maximum:
raise BodyLimitExceeded("request body is too large")
async def _send_rejection(send: Send, status: int, code: str) -> None:
body = json.dumps({"error": code}, separators=(",", ":")).encode("ascii")
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(body)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": body})
class LocalRequestLimitsMiddleware:
"""Validate headers and cap all ASGI body chunks before application parsing."""
def __init__(self, app: ASGIApp, *, authority: LocalAuthority, limits: RequestLimits) -> None:
self.app = app
self.authority = authority
self.limits = limits
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def secured_send(message: Message) -> None:
if message["type"] != "http.response.start":
await send(message)
return
headers = list(message.get("headers", []))
present = {name.lower() for name, _ in headers}
headers.extend(
(name, value) for name, value in _RESPONSE_SECURITY_HEADERS if name not in present
)
protected = dict(message)
protected["headers"] = headers
await send(protected)
headers = scope.get("headers", [])
if len(headers) > self.limits.max_headers or any(
len(name) + len(value) > self.limits.max_header_bytes for name, value in headers
):
await _send_rejection(secured_send, 431, "request_rejected")
return
if sum(len(name) + len(value) for name, value in headers) > self.limits.max_header_bytes:
await _send_rejection(secured_send, 431, "request_rejected")
return
try:
validate_host(scope, self.authority)
_validate_content_length(scope, self.limits.max_body_bytes)
except BodyLimitExceeded:
await _send_rejection(secured_send, 413, "request_too_large")
return
except SecurityError:
await _send_rejection(secured_send, 400, "request_rejected")
return
received = 0
request_complete = False
async def limited_receive() -> Message:
nonlocal received, request_complete
message = await receive()
if message["type"] == "http.disconnect":
if not request_complete:
raise ClientDisconnected("client disconnected")
return message
if message["type"] != "http.request":
return message
body = message.get("body", b"")
if not isinstance(body, bytes):
raise SecurityError("invalid request body")
received += len(body)
if received > self.limits.max_body_bytes:
raise BodyLimitExceeded("request body is too large")
if not message.get("more_body", False):
request_complete = True
return message
await self.app(scope, limited_receive, secured_send)
@dataclass(frozen=True, slots=True)
class AuthenticatedSession:
token: str
csrf_token: str
expires_at: float
class SessionStore:
"""An intentionally non-durable session store invalidated on process restart."""
def __init__(
self,
bootstrap_token: str,
*,
now: Callable[[], float] = time.monotonic,
bootstrap_ttl_seconds: int = 600,
session_ttl_seconds: int = 900,
) -> None:
if not isinstance(bootstrap_token, str) or not _SECRET.fullmatch(bootstrap_token):
raise ValueError("bootstrap token is invalid")
if (
isinstance(bootstrap_ttl_seconds, bool)
or not isinstance(bootstrap_ttl_seconds, int)
or isinstance(session_ttl_seconds, bool)
or not isinstance(session_ttl_seconds, int)
or not 1 <= bootstrap_ttl_seconds <= 3600
or not 1 <= session_ttl_seconds <= 3600
):
raise ValueError("session lifetime is invalid")
self._bootstrap_token = bootstrap_token.encode("ascii")
self._bootstrap_expires_at = now() + bootstrap_ttl_seconds
self._bootstrap_used = False
self._now = now
self._session_ttl_seconds = session_ttl_seconds
self._sessions: dict[str, AuthenticatedSession] = {}
def bootstrap(self, submitted: str) -> AuthenticatedSession:
if (
self._bootstrap_used
or self._now() > self._bootstrap_expires_at
or not _SECRET.fullmatch(submitted)
or not secrets.compare_digest(submitted.encode("ascii"), self._bootstrap_token)
):
raise AuthenticationError("bootstrap authentication failed")
self._bootstrap_used = True
return self._new_session()
def authenticate(self, request: Request) -> AuthenticatedSession:
token = _session_cookie(request)
session = self._sessions.get(token)
if session is None or self._now() > session.expires_at:
self._sessions.pop(token, None)
raise AuthenticationError("session authentication failed")
return session
def require_mutation(self, request: Request, authority: LocalAuthority) -> AuthenticatedSession:
session = self.authenticate(request)
validate_origin(request, authority)
csrf = _single_header(request.scope, b"x-csrf-token", required=True)
assert csrf is not None
submitted = _ascii(csrf)
if not _SECRET.fullmatch(submitted) or not secrets.compare_digest(
submitted.encode("ascii"), session.csrf_token.encode("ascii")
):
raise SecurityError("csrf validation failed")
return session
def rotate(self, session: AuthenticatedSession) -> AuthenticatedSession:
current = self._sessions.get(session.token)
if current != session or self._now() > session.expires_at:
self._sessions.pop(session.token, None)
raise AuthenticationError("session authentication failed")
del self._sessions[session.token]
return self._new_session()
def _new_session(self) -> AuthenticatedSession:
session = AuthenticatedSession(
token=secrets.token_urlsafe(32),
csrf_token=secrets.token_urlsafe(32),
expires_at=self._now() + self._session_ttl_seconds,
)
self._sessions[session.token] = session
return session
def _session_cookie(request: Request) -> str:
raw = _single_header(request.scope, b"cookie", required=True)
assert raw is not None
pairs: dict[str, str] = {}
for segment in _ascii(raw).split(";"):
name, separator, value = segment.strip().partition("=")
if not separator or not name or name in pairs:
raise AuthenticationError("session authentication failed")
pairs[name] = value
token = pairs.get(_COOKIE_NAME)
if token is None or not _SECRET.fullmatch(token):
raise AuthenticationError("session authentication failed")
return token
def set_session_cookie(response: Any, session: AuthenticatedSession, *, max_age: int) -> None:
"""Set a host-only HTTP-only local session cookie without a Domain attribute."""
response.set_cookie(
key=_COOKIE_NAME,
value=session.token,
max_age=max_age,
path="/",
secure=False,
httponly=True,
samesite="strict",
)
@@ -0,0 +1,124 @@
:root {
--canvas: #0a0c0d;
--surface: #111415;
--surface-raised: #141718;
--well: #0d0f10;
--line: #303536;
--line-strong: #50595b;
--text: #ecebe5;
--muted: #a8afb1;
--dim: #7e8789;
--orange: #f6a821;
--orange-hover: #ffc45d;
--success: #8bd2a0;
--danger: #f09a86;
--info: #8cc8df;
--focus: #ffc45d;
--space: 1rem;
font-family: Arial, Helvetica, sans-serif;
color: var(--text);
background: var(--canvas);
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 20rem; background: var(--canvas); color: var(--text); }
button, input { font: inherit; }
button, input[type="radio"], input[type="checkbox"] { accent-color: var(--orange); }
button { min-height: 44px; border: 1px solid var(--line-strong); border-radius: 0; background: var(--surface); color: var(--text); cursor: pointer; padding: .65rem .9rem; font-weight: 700; }
button:hover:not(:disabled) { background: #1b2021; border-color: var(--text); }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { color: var(--dim); cursor: not-allowed; border-color: var(--line); background: #101213; }
.button-primary { background: var(--orange); color: #12100b; border-color: var(--orange); }
.button-primary:hover:not(:disabled) { background: var(--orange-hover); border-color: var(--orange-hover); }
.button-danger { color: var(--danger); }
.quiet-danger { border-color: #6f4941; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.skip-link { position: fixed; z-index: 2; top: 0; left: .5rem; transform: translateY(-120%); background: var(--orange); color: #12100b; padding: .65rem; font-weight: 700; }
.skip-link:focus { transform: translateY(0); }
.shell { width: min(100% - 2rem, 100rem); margin: 0 auto; padding: 1.5rem 0 3rem; }
.command-header, .control-strip, .panel-heading { display: flex; gap: var(--space); justify-content: space-between; align-items: center; }
.command-header { border-bottom: 1px solid var(--line); padding: 0 0 1.25rem; }
.kicker { margin: 0 0 .35rem; color: var(--muted); font-size: .74rem; font-weight: 800; letter-spacing: .08em; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(1.65rem, 3vw, 2.35rem); letter-spacing: -.025em; }
h2 { margin-bottom: 0; font-size: 1.15rem; }
.header-state { text-align: right; }
.header-state strong, #capture-state, #review-state { display: block; font-size: .88rem; font-weight: 800; letter-spacing: .04em; }
.header-state span, #queue-state, #capture-count, .field-help, .action-help, figcaption { color: var(--muted); font-size: .88rem; }
.notice { display: flex; gap: .75rem; border: 1px solid var(--line); border-left: 2px solid var(--info); margin: 1rem 0; padding: .8rem 1rem; background: var(--well); }
.notice strong { color: var(--info); font-size: .78rem; letter-spacing: .06em; }
.notice[data-kind="success"] { border-left-color: var(--success); }
.notice[data-kind="success"] strong { color: var(--success); }
.notice[data-kind="error"], .notice[data-kind="conflict"] { border-left-color: var(--danger); }
.notice[data-kind="error"] strong, .notice[data-kind="conflict"] strong { color: var(--danger); }
.notice[data-kind="retained"] { border-left-color: var(--info); }
.notice[data-kind="retained"] strong { color: var(--info); }
.control-strip { border: 1px solid var(--line); background: var(--surface-raised); padding: 1rem; margin-bottom: 1rem; }
.control-strip p:last-child { margin: 0; }
.control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); }
.capture-panel { border-right: 1px solid var(--line); min-height: 42rem; }
.capture-panel, .review-panel { padding: 1rem; }
.panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; }
.empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; }
.capture-list { margin: 0; padding: 0; list-style: none; }
.capture-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
.capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); }
.capture-list .capture-id { overflow-wrap: anywhere; }
.capture-list .capture-meta { color: var(--muted); font-size: .8rem; }
.review-content { padding-top: 1rem; }
.evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; }
.evidence-frame img { display: block; width: 100%; max-height: 31rem; object-fit: contain; background: #000; }
.evidence-frame figcaption { padding-top: .65rem; overflow-wrap: anywhere; }
fieldset { margin: 0 0 1rem; padding: .75rem; border: 1px solid var(--line); min-width: 0; }
fieldset:disabled { opacity: .65; }
legend { padding: 0 .35rem; font-weight: 700; }
.choice-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
.choice { position: relative; display: inline-flex; min-height: 44px; align-items: center; border: 1px solid var(--line-strong); background: var(--well); cursor: pointer; }
.choice:hover { border-color: var(--text); }
.choice input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.choice span { padding: .65rem .75rem; }
.choice input:checked + span { background: #3c2a0c; color: var(--text); box-shadow: inset 0 -2px var(--orange); }
.choice input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
.validation-message { min-height: 1.5rem; margin: -.35rem 0 .75rem; color: var(--danger); }
.action-help { max-width: 70ch; margin: .8rem 0 0; line-height: 1.45; }
dialog { width: min(calc(100% - 2rem), 34rem); border: 1px solid var(--orange); border-radius: 0; padding: 1.3rem; color: var(--text); background: var(--surface-raised); box-shadow: 0 18px 60px #000; }
dialog::backdrop { background: rgb(0 0 0 / 72%); }
dialog p { line-height: 1.5; }
@media (max-width: 52rem) {
.shell { width: min(100% - 1rem, 100rem); padding-top: 1rem; }
.command-header, .control-strip { align-items: flex-start; flex-direction: column; }
.header-state { text-align: left; }
.workspace-grid { grid-template-columns: 1fr; }
.capture-panel { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
.capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; }
.capture-list button { margin-top: 0; }
}
@media (max-width: 33rem) {
.control-actions, .form-actions, .dialog-actions { display: grid; grid-template-columns: 1fr; width: 100%; }
.control-actions button, .form-actions button, .dialog-actions button { width: 100%; }
.notice { align-items: flex-start; flex-direction: column; gap: .25rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
@media (forced-colors: active) {
button, fieldset, .choice, .notice, .workspace-grid, .evidence-frame { border: 1px solid CanvasText; }
.button-primary, .choice input:checked + span { forced-color-adjust: none; background: Highlight; color: HighlightText; }
}
@@ -0,0 +1,286 @@
const taxonomy = Object.freeze({
weapon: { views: ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"], records: { detail: ["weapon", "weapon_talent"], blueprint: ["weapon", "weapon_talent"], crafting: ["weapon"], stat_range: ["weapon"], talent_grid: ["weapon_talent"], augmentation: ["weapon_augmentation_profile"] } },
gear: { views: ["detail", "blueprint", "talent", "augmentation"], records: { detail: ["gear", "brand_set"], blueprint: ["gear", "brand_set"], talent: ["gear_talent"], augmentation: ["gear_augmentation_profile"] } },
skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } },
os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } },
specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } },
unknown: { views: ["unknown"], records: { unknown: [] } }
});
const families = Object.keys(taxonomy);
const gearSlots = ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"];
const abstentionReasons = ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"];
const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/;
const $ = (id) => document.getElementById(id);
const els = Object.freeze({
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
});
let csrfToken = null;
let activeCapture = null;
let captures = [];
let armed = null;
let pending = false;
let returnFocus = null;
function setText(element, value) { element.textContent = value; }
function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
function label(value) { return value.replaceAll("_", " "); }
function notice(kind, title, copy) { els.notice.dataset.kind = kind; setText(els.noticeTitle, title); setText(els.noticeCopy, copy); }
function hasValidCsrf(value) { return typeof value === "string" && csrfPattern.test(value); }
function selected(name) { return document.querySelector(`input[name="${name}"]:checked`)?.value || null; }
function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); }
function currentFamily() { return selected("family"); }
function currentView() { return selected("view"); }
function syncControls() {
const locked = !csrfToken;
els.start.disabled = pending || locked || armed === true;
els.stop.disabled = pending || locked || armed !== true;
els.refresh.disabled = pending || locked;
els.save.disabled = pending || locked || !activeCapture || !isValidLabel();
els.reject.disabled = pending || locked || !activeCapture;
els.remove.disabled = pending || locked || !activeCapture;
document.querySelectorAll("#capture-list button").forEach((button) => {
button.disabled = pending || locked;
});
document.querySelectorAll("#label-form input").forEach((input) => {
const fieldset = input.closest("fieldset");
input.disabled = pending || locked || !activeCapture || Boolean(fieldset?.disabled || fieldset?.hidden);
});
els.labelForm.setAttribute("aria-busy", String(pending));
}
function setBusy(value) { pending = value; syncControls(); }
function clearActiveDetail() {
activeCapture = null;
els.media.removeAttribute("src");
els.reviewContent.hidden = true;
els.reviewEmpty.hidden = false;
els.reviewTitle.textContent = "No capture selected";
els.reviewState.textContent = csrfToken ? "READY" : "LOCKED";
renderLabelForm(null);
}
function lock(message) {
csrfToken = null;
armed = null;
captures = [];
clearActiveDetail();
els.sessionState.textContent = "LOCKED";
els.captureState.textContent = "DISARMED";
els.queueState.textContent = "Queue unavailable";
els.listEmpty.hidden = false;
setText(els.listEmpty, message);
clearNode(els.list);
els.count.textContent = "0 items";
setBusy(false);
}
function validSession(payload) {
return Boolean(payload) && hasValidCsrf(payload.csrf_token) && typeof payload.armed === "boolean" && Number.isInteger(payload.queue_depth) && Number.isInteger(payload.queue_capacity) && payload.queue_depth >= 0 && payload.queue_depth <= payload.queue_capacity && payload.queue_capacity <= 256;
}
function updateSession(payload) {
armed = payload.armed;
els.sessionState.textContent = "UNLOCKED";
els.captureState.textContent = armed ? "ARMED" : "DISARMED";
els.queueState.textContent = `Queue ${payload.queue_depth} / ${payload.queue_capacity}`;
if (!activeCapture) els.reviewState.textContent = "READY";
syncControls();
}
function safeJson(response) { return response.json().catch(() => null); }
async function request(path, options = {}, mutation = false) {
const headers = new Headers(options.headers || {});
if (mutation) { headers.set("Content-Type", "application/json"); headers.set("X-CSRF-Token", csrfToken || ""); }
let response;
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } catch { notice("error", "NETWORK ERROR", "The local service did not respond. Your current review remains unchanged."); return null; }
if (response.status === 401 || response.status === 403) { lock("Local session expired or restarted. Open the provided local address again."); notice("error", "SESSION LOCKED", "The local session is no longer available. No action was retried."); return null; }
const payload = await safeJson(response);
if (payload === null) { notice("error", "RESPONSE ERROR", "The local service returned an unusable response. Your current review remains unchanged."); return null; }
if (!response.ok) {
const retained = response.status === 409 && payload.error === "retained";
notice(retained ? "retained" : response.status === 409 ? "conflict" : "error", retained ? "RETAINED" : response.status === 409 ? "CONFLICT" : "ACTION FAILED", retained ? "Retention policy kept this evidence. No deletion occurred." : "The local service could not complete that action.");
return null;
}
return payload;
}
function acceptMutationCsrf(payload) {
if (hasValidCsrf(payload?.csrf_token)) { csrfToken = payload.csrf_token; return true; }
lock("The local service returned an invalid session update. Open the provided local address again.");
notice("error", "SESSION LOCKED", "The confirmed action was not retried.");
return false;
}
function addChoice(container, type, name, value, checked, onChange) {
const wrapper = document.createElement("label"); wrapper.className = "choice";
const input = document.createElement("input"); input.type = type; input.name = name; input.value = value; input.checked = checked; input.addEventListener("change", onChange);
const text = document.createElement("span"); text.textContent = label(value);
wrapper.append(input, text); container.append(wrapper);
}
function setChecked(name, value) {
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => { input.checked = input.value === value; });
}
function setCheckedMembers(name, values) {
const selectedValues = new Set(values);
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => {
input.checked = selectedValues.has(input.value);
});
}
function renderFamilies() {
clearNode(els.family);
families.forEach((family) => addChoice(els.family, "radio", "family", family, false, () => { renderViews(); renderDependentFields(); }));
}
function renderViews() {
const family = currentFamily();
clearNode(els.view);
els.viewFieldset.disabled = !family;
if (!family) return;
taxonomy[family].views.forEach((view) => addChoice(els.view, "radio", "view", view, false, renderDependentFields));
}
function renderDependentFields() {
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown"; const isGear = family === "gear";
els.gearFieldset.hidden = !isGear; els.gearFieldset.disabled = !isGear;
els.abstentionFieldset.hidden = !unknown; els.abstentionFieldset.disabled = !unknown;
els.recordFieldset.disabled = !family || !view || unknown;
clearNode(els.gear); clearNode(els.records); clearNode(els.abstention);
if (isGear) gearSlots.forEach((slot) => addChoice(els.gear, "radio", "gear_slot", slot, false, validateLabel));
if (unknown) abstentionReasons.forEach((reason) => addChoice(els.abstention, "radio", "abstention_reason", reason, false, validateLabel));
if (!unknown && family && view) (taxonomy[family].records[view] || []).forEach((record) => addChoice(els.records, "checkbox", "record_type", record, false, validateLabel));
validateLabel();
}
function labelPayload() {
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown";
return { family, view, gear_slot: family === "gear" ? selected("gear_slot") : null, record_types: unknown ? [] : selectedAll("record_type"), confidence: 1.0, abstention_reason: unknown ? selected("abstention_reason") : null };
}
function isValidLabel() {
const payload = labelPayload();
if (!payload.family || !payload.view) return false;
if (payload.family === "unknown") return payload.view === "unknown" && !payload.gear_slot && payload.record_types.length === 0 && Boolean(payload.abstention_reason);
return payload.record_types.length > 0 && (payload.family !== "gear" || Boolean(payload.gear_slot));
}
function validateLabel() {
const payload = labelPayload(); let message = "";
if (!payload.family) message = "Select the evidence family.";
else if (!payload.view) message = "Select the screen view.";
else if (payload.family === "unknown" && !payload.abstention_reason) message = "Select an explicit abstention reason for an unknown screen.";
else if (payload.family === "gear" && !payload.gear_slot) message = "Select the Gear slot shown by this evidence.";
else if (payload.family !== "unknown" && payload.record_types.length === 0) message = "Select at least one compatible canonical record type.";
setText(els.validation, message);
if (!pending) syncControls();
return !message;
}
function renderLabelForm(classification) {
renderFamilies();
if (!classification) { renderViews(); renderDependentFields(); return; }
setChecked("family", classification.family);
renderViews();
setChecked("view", classification.view);
renderDependentFields();
if (classification.gear_slot) setChecked("gear_slot", classification.gear_slot);
setCheckedMembers("record_type", classification.record_types || []);
if (classification.abstention_reason) setChecked("abstention_reason", classification.abstention_reason);
validateLabel();
}
function renderCaptures() {
clearNode(els.list); els.count.textContent = `${captures.length} ${captures.length === 1 ? "item" : "items"}`; els.listEmpty.hidden = captures.length > 0;
if (!captures.length) { setText(els.listEmpty, "EMPTY QUEUE: capture evidence will appear after a manual refresh."); return; }
captures.forEach((capture) => {
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-current", String(activeCapture?.capture_id === capture.capture_id));
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = capture.capture_id;
const meta = document.createElement("span"); meta.className = "capture-meta"; meta.textContent = `${capture.state} · ${capture.received_at}`;
button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item);
});
syncControls();
}
function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); }
async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) {
if (!csrfToken || pending) return false;
setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue.");
const payload = await request("/api/captures");
if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; }
captures = payload.captures;
if (activeCapture && !captures.some((capture) => capture.capture_id === activeCapture.capture_id)) clearActiveDetail();
renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true;
}
async function selectCapture(captureId, { announce = true } = {}) {
if (!csrfToken || pending) return;
setBusy(true); if (announce) notice("info", "LOADING", "Loading selected private evidence.");
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}`);
if (!payload?.capture) { setBusy(false); return; }
activeCapture = payload.capture; els.reviewContent.hidden = false; els.reviewEmpty.hidden = true; els.reviewTitle.textContent = activeCapture.capture_id; els.reviewState.textContent = activeCapture.state;
els.meta.textContent = `State: ${activeCapture.state} · Received: ${activeCapture.received_at}`;
els.media.removeAttribute("src"); els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
renderCaptures(); renderLabelForm(activeCapture.classification); if (announce) notice("success", "CAPTURE READY", "Review the evidence, then save a manual label or choose a separate evidence action."); setBusy(false);
}
function routeForAction(action) {
if (action === "start") return ["/api/session/start", "START"];
if (action === "stop") return ["/api/session/stop", "STOP"];
if ((action === "reject" || action === "delete") && activeCapture) return [`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`, action === "reject" ? "REJECT" : "DELETE"];
return null;
}
function showConfirmation(action, source) {
if ((action === "reject" || action === "delete") && !activeCapture) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); return; }
const messages = { start: ["START SESSION", "Start capture now? This is the only action that can arm capture.", "Start Session"], stop: ["STOP SESSION", "Stop capture now? New screenshots will not enter the queue after the session stops.", "Stop Session"], reject: ["REJECT EVIDENCE", "Reject this evidence? The evidence remains retained for review and will not be deleted.", "Reject evidence"], delete: ["DELETE CAPTURE", "Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.", "Delete capture"] };
const message = messages[action]; if (!message) return;
returnFocus = source; els.dialog.returnValue = "cancel"; els.dialog.dataset.action = action;
els.dialogKicker.textContent = "EXPLICIT CONFIRMATION"; els.dialogTitle.textContent = message[0]; els.dialogCopy.textContent = message[1]; els.dialogSubmit.textContent = message[2]; els.dialogSubmit.className = action === "delete" || action === "reject" ? "button button-danger" : "button button-primary";
els.dialog.showModal(); els.dialogSubmit.focus();
}
async function confirmed(action, fallbackFocus) {
if (pending) return;
const route = routeForAction(action);
if (!route) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); fallbackFocus?.focus(); return; }
setBusy(true); notice("info", "PENDING", "The local service is processing your confirmed action.");
const [path, confirmation] = route; const payload = await request(path, { method: "POST", body: JSON.stringify({ confirmation }) }, true);
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); fallbackFocus?.focus(); return; }
if ((action === "start" || action === "stop") && !validSession(payload)) { lock("The local service returned an invalid session update. Open the provided local address again."); setBusy(false); return; }
if (action === "delete") clearActiveDetail();
if (action === "start" || action === "stop") updateSession(payload);
const successFocus = action === "start" ? els.stop : action === "stop" ? els.start : action === "delete" ? els.refresh : fallbackFocus;
setBusy(false); if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(successFocus); return; }
notice("success", "SUCCESS", action === "reject" ? "Evidence rejected and retained." : action === "delete" ? "Deletion request completed." : `Capture session ${action === "start" ? "armed" : "disarmed"}.`);
focusIfAvailable(successFocus);
}
async function initialize() {
renderLabelForm(null);
let fragment = window.location.hash; let token = null;
if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; }
if (fragment) history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
fragment = "";
if (token) {
const bootstrap = await request("/api/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); token = null;
if (!hasValidCsrf(bootstrap?.csrf_token)) { lock("The local service could not establish a safe session. Open the provided local address again."); return; }
csrfToken = bootstrap.csrf_token;
}
const session = await request("/api/session");
if (!validSession(session)) { lock("LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session."); return; }
csrfToken = session.csrf_token; updateSession(session); if (!(await refreshCaptures({ announce: false }))) return;
const startupCopy = session.armed ? "Capture is ARMED. Stop Session requires explicit confirmation." : "Capture is DISARMED. Start Session requires explicit confirmation.";
notice("success", "LOCAL SESSION READY", startupCopy);
}
els.start.addEventListener("click", () => showConfirmation("start", els.start));
els.stop.addEventListener("click", () => showConfirmation("stop", els.stop));
els.refresh.addEventListener("click", () => refreshCaptures({ fallbackFocus: els.refresh }));
els.labelForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeCapture || !validateLabel() || pending) return;
const captureId = activeCapture.capture_id;
setBusy(true); notice("info", "PENDING", "Saving the manual reviewed label.");
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}/label`, { method: "POST", body: JSON.stringify(labelPayload()) }, true);
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; }
setBusy(false);
if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; }
if (captures.some((capture) => capture.capture_id === captureId)) await selectCapture(captureId, { announce: false });
notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0.");
focusIfAvailable(els.save);
});
els.reject.addEventListener("click", () => showConfirmation("reject", els.reject));
els.remove.addEventListener("click", () => showConfirmation("delete", els.remove));
els.dialog.addEventListener("close", () => {
const action = els.dialog.dataset.action; const shouldConfirm = els.dialog.returnValue === "confirm";
els.dialog.dataset.action = "";
const focusTarget = returnFocus;
if (focusTarget) focusTarget.focus();
returnFocus = null;
if (shouldConfirm && action) confirmed(action, focusTarget);
});
els.dialog.addEventListener("cancel", () => { els.dialog.returnValue = "cancel"; });
els.media.addEventListener("error", () => notice("error", "MEDIA UNAVAILABLE", "The private evidence image could not be loaded. Refresh captures before retrying."));
initialize();
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Local Capture Review</title>
<link rel="stylesheet" href="/static/review.css" />
<script type="module" src="/static/review.js"></script>
</head>
<body>
<a class="skip-link" href="#workspace">Skip to review workspace</a>
<main class="shell" id="workspace">
<header class="command-header">
<div>
<p class="kicker">LOCAL OPERATIONS / CAPTURE REVIEW</p>
<h1>Evidence intake</h1>
</div>
<div class="header-state" aria-label="Session status">
<strong id="session-state">LOCKED</strong>
<span id="queue-state">Queue unavailable</span>
</div>
</header>
<section class="notice" id="notice" aria-live="polite" aria-atomic="true">
<strong id="notice-title">STARTUP</strong>
<span id="notice-copy">Checking this local session.</span>
</section>
<section class="control-strip" aria-label="Capture controls">
<div>
<p class="kicker">CAPTURE SESSION</p>
<p id="capture-state">DISARMED</p>
</div>
<div class="control-actions">
<button class="button button-primary" id="start-button" type="button" disabled>Start Session</button>
<button class="button" id="stop-button" type="button" disabled>Stop Session</button>
<button class="button" id="refresh-button" type="button" disabled>Refresh captures</button>
</div>
</section>
<section class="workspace-grid" aria-label="Capture review workspace">
<aside class="capture-panel" aria-labelledby="capture-list-title">
<div class="panel-heading">
<div>
<p class="kicker">PRIVATE QUEUE</p>
<h2 id="capture-list-title">Captures</h2>
</div>
<span id="capture-count">0 items</span>
</div>
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
</aside>
<section class="review-panel" aria-labelledby="review-title">
<div class="panel-heading">
<div>
<p class="kicker">MANUAL REVIEW</p>
<h2 id="review-title">No capture selected</h2>
</div>
<span id="review-state">LOCKED</span>
</div>
<div class="review-empty" id="review-empty">
<p>Select a capture after the local session is unlocked. Media remains private to this browser session.</p>
</div>
<div class="review-content" id="review-content" hidden>
<figure class="evidence-frame">
<img id="capture-media" alt="Private captured game screen" />
<figcaption id="capture-meta"></figcaption>
</figure>
<form id="label-form" novalidate>
<fieldset id="family-fieldset">
<legend>Evidence family</legend>
<div class="choice-grid" id="family-options"></div>
</fieldset>
<fieldset id="view-fieldset" disabled>
<legend>Screen view</legend>
<div class="choice-grid" id="view-options"></div>
</fieldset>
<fieldset id="gear-slot-fieldset" hidden disabled>
<legend>Gear slot</legend>
<div class="choice-grid" id="gear-slot-options"></div>
</fieldset>
<fieldset id="record-types-fieldset" disabled>
<legend>Canonical records evidenced</legend>
<p class="field-help" id="record-types-help">Choose every record type shown by this screen.</p>
<div class="choice-grid" id="record-type-options"></div>
</fieldset>
<fieldset id="abstention-fieldset" hidden disabled>
<legend>Unknown screen reason</legend>
<div class="choice-grid" id="abstention-options"></div>
</fieldset>
<p class="validation-message" id="label-validation" role="status"></p>
<div class="form-actions">
<button class="button button-primary" id="save-label-button" type="submit" disabled>Save reviewed label</button>
<button class="button button-danger" id="reject-button" type="button" disabled>Reject evidence</button>
<button class="button button-danger quiet-danger" id="delete-button" type="button" disabled>Delete capture</button>
</div>
<p class="action-help">Rejecting retains evidence. Deletion requires separate confirmation and may be retained by policy.</p>
</form>
</div>
</section>
</section>
</main>
<dialog class="confirmation-dialog" id="confirmation-dialog" aria-labelledby="confirmation-title">
<form method="dialog" id="confirmation-form">
<p class="kicker" id="confirmation-kicker">CONFIRMATION REQUIRED</p>
<h2 id="confirmation-title">Confirm action</h2>
<p id="confirmation-copy"></p>
<div class="dialog-actions">
<button class="button" id="confirmation-cancel" value="cancel" type="submit">Cancel</button>
<button class="button button-primary" id="confirmation-submit" value="confirm" type="submit">Confirm</button>
</div>
</form>
</dialog>
</body>
</html>