Add game data capture vertical slice
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user