av-test: generic page and bounded numeric-only receipts

This commit is contained in:
jenkins 2026-10-02 21:47:13 -05:00
parent 1e06701d69
commit dd394b3eb3
11 changed files with 392 additions and 22 deletions

View File

@ -1,4 +1,4 @@
# Static assets only; no API, uploads, credentials or outbound connections.
# Static page plus numeric-only receipts; no media, installed credentials or egress.
apiVersion: apps/v1
kind: Deployment
metadata:
@ -36,14 +36,30 @@ spec:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 100m, memory: 64Mi}
readinessProbe:
httpGet: {path: /lesavka-av-test/, port: http}
httpGet: {path: /av-test/, port: http}
livenessProbe:
httpGet: {path: /lesavka-av-test/, port: http}
httpGet: {path: /av-test/, port: http}
initialDelaySeconds: 10
volumeMounts:
- {name: config, mountPath: /etc/nginx/observer, readOnly: true}
- {name: assets, mountPath: /usr/share/nginx/html/lesavka-av-test, readOnly: true}
- {name: assets, mountPath: /usr/share/nginx/html/av-test, readOnly: true}
- {name: tmp, mountPath: /tmp}
- name: receipts
image: python:3.13-slim@sha256:9662417aace5ae7b8e2609cce472b72a8958e134ba372808abe9cc1a0c0125e6
command: [python, -u, -B, /app/server.py]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 100m, memory: 64Mi}
readinessProbe:
exec:
command: [python, -c, "import socket; socket.create_connection(('127.0.0.1', 8081), 2).close()"]
periodSeconds: 10
volumeMounts:
- {name: receipts, mountPath: /app, readOnly: true}
volumes:
- name: config
configMap: {name: lesavka-av-observer-nginx}
@ -51,3 +67,5 @@ spec:
configMap: {name: lesavka-av-observer-assets}
- name: tmp
emptyDir: {medium: Memory, sizeLimit: 8Mi}
- name: receipts
configMap: {name: lesavka-av-observer-receipts}

View File

@ -1,18 +1,19 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'none'; media-src 'self' blob:; style-src 'self'; object-src 'none'; base-uri 'none'">
<title>Lesavka — bundled A/V observer</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self'; media-src 'self' blob:; style-src 'self'; object-src 'none'; base-uri 'none'">
<title>A/V Test</title>
<link rel="stylesheet" href="style.css">
<main><h1>Bundled A/V observer</h1>
<p>No recording, uploads or saved camera/microphone content. Select the RCT's Lesavka video and microphone devices.</p>
<main><h1>A/V Test</h1>
<p>Select a camera and microphone. No recording or media uploads. Numeric timing/activity metrics and test status are reported automatically.</p>
<div><button id="devices">Choose devices…</button>
<label>Video <select id="video-device"></select></label>
<label>Microphone <select id="audio-device"></select></label></div>
<div><button id="start" disabled>Start 2-minute check</button><button id="stop" disabled>Stop</button><button id="export" disabled>Save metrics</button></div>
<p id="status" role="status">Stopped</p>
<p id="report-status" role="status">Reporting starts with the test.</p>
<video id="preview" autoplay muted playsinline></video>
<pre id="metrics">No observations.</pre>
<p>Sync and capture-to-RCT freshness: <strong>not measured by this observer</strong>.
<p>Audio/video sync and end-to-end delay: <strong>not measured by this check</strong>.
Video callback gaps can include browser scheduling. Silence is not proof of audio loss.
Use the qualified paired-marker/clock-bounded probe for release acceptance.</p>
A separate paired-signal measurement is needed to verify synchronization.</p>
</main><script type="module" src="observer.js"></script></html>

View File

@ -15,6 +15,12 @@ spec:
- host: bstein.dev
http:
paths:
- path: /av-test
pathType: Prefix
backend:
service:
name: lesavka-av-observer
port: {number: 80}
- path: /lesavka-av-test
pathType: Prefix
backend:

View File

@ -13,5 +13,11 @@ configMapGenerator:
- index.html
- observer.js
- metrics.js
- reporting.js
- activity-worklet.js
- style.css
- name: lesavka-av-observer-receipts
files:
- report-service/server.py
- report-service/receipts.py
- report-service/validation.py

View File

@ -23,7 +23,7 @@ export class VideoMetrics {
}
export function receipt(video,audio,settings,elapsedMs,reason) {
return {schema:"lesavka.rct-av-observer.v1",kind:"diagnostic-only",reason,elapsedMs,
return {schema:"av-test.observer.v2",kind:"diagnostic-only",reason,elapsedMs,
settings,video:video.summary(),audio,
syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
releaseEligible:false,recordedMedia:false};

View File

@ -7,7 +7,9 @@ http {
default_type application/octet-stream;
access_log off;
server_tokens off;
client_max_body_size 1k;
client_max_body_size 4k;
client_body_timeout 5s;
client_header_timeout 5s;
client_body_temp_path /tmp/client;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
@ -17,19 +19,37 @@ http {
listen 8080;
absolute_redirect off;
root /usr/share/nginx/html;
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), display-capture=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
location = /lesavka-av-test { return 308 /lesavka-av-test/; }
location = /lesavka-av-test/ { try_files /lesavka-av-test/index.html =404; }
location ~ ^/lesavka-av-test/(index\.html|observer\.js|metrics\.js|activity-worklet\.js|style\.css)$ {
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
location = /lesavka-av-test { return 308 /av-test/; }
location = /lesavka-av-test/ { return 308 /av-test/; }
location = /lesavka-av-test/index.html { return 308 /av-test/; }
location = /av-test { return 308 /av-test/; }
location = /av-test/ {
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
try_files /av-test/index.html =404;
}
location ~ ^/av-test/(index\.html|observer\.js|metrics\.js|reporting\.js|activity-worklet\.js|style\.css)$ {
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
try_files $uri =404;
}
location ~ ^/av-test/api/(start|report)$ {
if ($request_method != POST) { return 405; }
proxy_pass http://127.0.0.1:8081;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
proxy_send_timeout 5s;
proxy_pass_request_headers off;
proxy_set_header Content-Type $http_content_type;
proxy_set_header Origin $http_origin;
proxy_set_header Authorization $http_authorization;
proxy_set_header Host localhost;
}
location / { return 404; }
}
}

View File

@ -1,6 +1,7 @@
import {VideoMetrics,receipt} from './metrics.js';
import {RunReporter} from './reporting.js';
const el=id=>document.getElementById(id);
let generation=0, current=null, last=null;
let generation=0, current=null, last=null, reportingRun=null;
const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop());
function release(run) {
if(run.released)return;
@ -18,6 +19,7 @@ function stop(reason='operator') {
if(run) {
release(run);
last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason);
run.reporter.finish(last);
el('metrics').textContent=JSON.stringify(last,null,2);
el('export').disabled=false;
}
@ -40,11 +42,11 @@ el('devices').onclick=async()=>{
if(generation!==ticket)return;
for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) {
const select=el(id), saved=select.value;
select.replaceChildren(new Option('Select Lesavka device',''));
select.replaceChildren(new Option('Select a device',''));
for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId));
select.value=saved;
}
el('status').textContent='Select both Lesavka devices, then Start.';
el('status').textContent='Select a camera and microphone, then Start.';
el('devices').disabled=false; el('stop').disabled=true;
} catch { if(generation===ticket)stop('device permission unavailable'); }
finally {clearTimeout(timer);}
@ -61,6 +63,10 @@ el('start').onclick=async()=>{
const ticket=++generation;
const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}};
current=run; last=null;
reportingRun=run;
run.reporter=new RunReporter(message=>{
if(reportingRun===run)el('report-status').textContent=message;
});
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
el('status').textContent='Starting…';
run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
@ -84,7 +90,9 @@ el('start').onclick=async()=>{
run.node.port.onmessage=({data})=>{
if(current!==run)return;
run.audio=data; run.node.port.postMessage('ack');
el('metrics').textContent=JSON.stringify(receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'),null,2);
const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing');
el('metrics').textContent=JSON.stringify(sample,null,2);
if(run.observing)run.reporter.observe(sample);
};
run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks()));
run.source.connect(run.node);run.node.connect(run.context.destination);
@ -95,6 +103,7 @@ el('start').onclick=async()=>{
if(generation!==ticket)return;
clearTimeout(run.setupTimer);
run.started=performance.now();
run.observing=true;
function frame(now,metadata) {
if(current!==run)return;
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
@ -110,7 +119,7 @@ el('stop').onclick=()=>stop();
el('export').onclick=()=>{
if(!last)return;
const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'}));
const link=document.createElement('a');link.href=url;link.download='lesavka-av-observer.json';link.click();
const link=document.createElement('a');link.href=url;link.download='av-test-metrics.json';link.click();
setTimeout(()=>URL.revokeObjectURL(url),1000);
};
window.addEventListener('pagehide',()=>stop('page closed'));

View File

@ -0,0 +1,68 @@
"""Bounded ephemeral write capabilities; validated numeric receipts go to stdout.
These are unverified browser diagnostics, never release-gate attestations.
No public read API, database, cookies, media, device identities or credentials.
"""
from collections import deque
import hashlib
import json
import secrets
import threading
import time
from validation import validate_report
class Refused(Exception):
def __init__(self, status):
self.status = status
class ReceiptStore:
def __init__(self, emit, clock=time.monotonic, wall=time.time):
self.emit, self.clock, self.wall = emit, clock, wall
self.runs, self.starts = {}, deque()
self.lock = threading.Lock()
def start(self):
with self.lock:
now = self.clock()
self.runs = {key: value for key, value in self.runs.items() if value["expires"] > now}
while self.starts and now-self.starts[0] >= 60:
self.starts.popleft()
if len(self.starts) >= 12 or len(self.runs) >= 64:
raise Refused(429)
run_id, token = secrets.token_hex(16), secrets.token_urlsafe(32)
self.runs[run_id] = {"token": hashlib.sha256(token.encode()).digest(), "expires": now+600,
"sequence": 0, "digest": None, "terminal": False, "last_progress": None}
self.starts.append(now)
self.log({"run_id": run_id, "sequence": 0, "state": "starting", "metrics": None})
return {"run_id": run_id, "write_token": token}
def report(self, body, token):
data = validate_report(body)
if type(token) is not str or len(token) != 43:
raise Refused(401)
with self.lock:
now = self.clock()
run = self.runs.get(data["run_id"])
if not run or run["expires"] <= now or not secrets.compare_digest(
run["token"], hashlib.sha256(token.encode()).digest()):
raise Refused(401)
digest = hashlib.sha256(json.dumps(data, sort_keys=True, allow_nan=False).encode()).digest()
if data["sequence"] == run["sequence"] and run["digest"] == digest:
return # Idempotent network retry, no duplicate log entry.
if run["terminal"] or data["sequence"] <= run["sequence"]:
raise Refused(409)
if data["state"] == "running" and run["last_progress"] is not None and now-run["last_progress"] < 8:
raise Refused(429)
self.log(data)
run.update(sequence=data["sequence"], digest=digest, terminal=data["state"] != "running")
if data["state"] == "running":
run["last_progress"] = now
else:
run["expires"] = now+60 # Retain briefly for idempotent final retry.
def log(self, data):
self.emit({"schema": "av-test.receipt.v1", "kind": "unverified-browser-diagnostic",
"received_unix_ms": int(self.wall()*1000), "release_eligible": False, **data})

View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Loopback-only aggregate receipt service behind the site's exact-path proxy."""
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
import json
import os
import threading
from receipts import ReceiptStore, Refused
from validation import no_duplicate_fields
MAX_BODY = 4096
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass # Never log request paths, headers, tokens, bodies or errors.
def reply(self, status, data=None):
body = json.dumps(data, allow_nan=False).encode() if data is not None else b""
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def do_GET(self):
self.reply(405)
do_HEAD = do_GET
do_OPTIONS = do_GET
def do_POST(self):
try:
if self.path not in ("/av-test/api/start", "/av-test/api/report"):
return self.reply(404)
if any(len(self.headers.get_all(name, [])) > 1 for name in ("Origin", "Authorization", "Content-Type")):
return self.reply(400)
if self.headers.get("Origin") != self.server.origin:
return self.reply(403)
if self.headers.get("Content-Type") != "application/json" or self.headers.get("Transfer-Encoding"):
return self.reply(415)
sizes = self.headers.get_all("Content-Length", [])
if len(sizes) != 1 or not sizes[0].isdigit() or not 0 < int(sizes[0]) <= MAX_BODY:
return self.reply(413)
raw = self.rfile.read(int(sizes[0]))
if len(raw) != int(sizes[0]):
return self.reply(400)
data = json.loads(raw, object_pairs_hook=no_duplicate_fields)
if self.path == "/av-test/api/start":
if data != {}:
return self.reply(400)
return self.reply(201, self.server.store.start())
authorization = self.headers.get("Authorization", "")
token = authorization[7:] if authorization.startswith("Bearer ") else ""
self.server.store.report(data, token)
self.reply(204)
except Refused as error:
self.reply(error.status)
except (ValueError, UnicodeError, RecursionError, TypeError):
self.reply(400)
except (OSError, TimeoutError):
pass
class Server(ThreadingMixIn, HTTPServer):
daemon_threads = True
def __init__(self, address, origin, store):
self.origin, self.store = origin, store
self.workers = threading.BoundedSemaphore(4)
super().__init__(address, Handler)
def get_request(self):
connection, address = super().get_request()
connection.settimeout(3)
return connection, address
def process_request(self, request, client_address):
if not self.workers.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.workers.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.workers.release()
def handle_error(self, *_args):
pass # No raw exception/request logging, including unexpected input.
if __name__ == "__main__":
store = ReceiptStore(lambda entry: print(json.dumps(entry, allow_nan=False), flush=True))
Server(("127.0.0.1", 8081), os.environ.get("OBSERVER_ORIGIN", "https://bstein.dev"), store).serve_forever()

View File

@ -0,0 +1,48 @@
"""Strict aggregate-only receipt schema: unknown fields and text are refused."""
import math
STATES = {"running", "completed", "hidden", "closed", "device-change", "stopped", "failed"}
BOUNDS = {
"elapsed_ms": 180_000,
"video_width": 8192, "video_height": 8192, "video_fps": 240,
"audio_rate": 384_000, "audio_channels": 32, "audio_context_rate": 384_000,
"video_callbacks": 100_000, "video_unobserved": 100_000, "video_resets": 100_000,
"video_interval_p95_ms": 180_000, "video_gap_max_ms": 180_000,
"audio_input_frames": 100_000_000, "audio_processed_frames": 100_000_000,
"audio_zero_frames": 100_000_000, "audio_rms": 32, "audio_peak": 32,
}
def no_duplicate_fields(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate field")
result[key] = value
return result
def validate_report(data):
"""Return a rebuilt, bounded numeric receipt, never caller-owned text."""
if type(data) is not dict or set(data) != {"run_id", "sequence", "state", "metrics"}:
raise ValueError("report shape")
run_id, sequence, state = data["run_id"], data["sequence"], data["state"]
if type(run_id) is not str or len(run_id) != 32 or any(c not in "0123456789abcdef" for c in run_id):
raise ValueError("run id")
if type(sequence) is not int or not 1 <= sequence <= 64:
raise ValueError("sequence")
if type(state) is not str or state not in STATES:
raise ValueError("state")
metrics = data["metrics"]
if type(metrics) is not dict or metrics.keys() != BOUNDS.keys():
raise ValueError("metric fields")
clean = {}
for name, limit in BOUNDS.items():
value = metrics[name]
if value is not None and (type(value) not in (int, float) or not 0 <= value <= limit or not math.isfinite(value)):
raise ValueError("metric value")
clean[name] = value
if clean["elapsed_ms"] is None:
raise ValueError("elapsed missing")
return {"run_id": run_id, "sequence": sequence, "state": state, "metrics": clean}

View File

@ -0,0 +1,91 @@
// Only the explicit numeric projection below can leave the browser. Never
// pass a receipt, device object, error string, frame, PCM or DOM value to fetch.
export function reportMetrics(receipt) {
const finite=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0 ? value : null;
const v=receipt.video??{},a=receipt.audio??{},s=receipt.settings??{};
return Object.fromEntries(Object.entries({
elapsed_ms:receipt.elapsedMs,
video_width:s.video?.width,video_height:s.video?.height,video_fps:s.video?.frameRate,
audio_rate:s.audio?.sampleRate,audio_channels:s.audio?.channelCount,
video_callbacks:v.callbacks,video_unobserved:v.unobservedPresentedFrames,
video_resets:v.clockOrCounterResets,video_interval_p95_ms:v.callbackIntervalP95Ms,
video_gap_max_ms:v.callbackGapMaxMs,audio_input_frames:a.inputFrames,
audio_processed_frames:a.processedFrames,audio_zero_frames:a.exactZeroFrames,
audio_rms:a.rms,audio_peak:a.peak,audio_context_rate:a.sampleRate,
}).map(([key,value])=>[key,finite(value)]));
}
export function stopState(reason) {
if(reason==='120 seconds complete')return 'completed';
if(reason==='page hidden; timing invalid')return 'hidden';
if(reason==='page closed')return 'closed';
if(reason==='device ended'||reason==='device selection changed')return 'device-change';
if(reason==='operator'||reason==='selecting devices')return 'stopped';
return 'failed';
}
export class RunReporter {
constructor(status,request=globalThis.fetch.bind(globalThis)) {
this.status=status;this.request=request;this.pending=null;this.busy=false;
this.terminal=false;this.sequence=0;this.lastProgress=-Infinity;this.available=true;
status('Reporting: connecting…');
this.ready=this.open();
}
async open() {
try {
const r=await this.request('/av-test/api/start',{method:'POST',credentials:'omit',
headers:{'Content-Type':'application/json'},body:'{}',signal:AbortSignal.timeout(5000)});
if(!r.ok)throw new Error('start rejected');
const data=await r.json();
if(!/^[a-f0-9]{32}$/.test(data.run_id)||!/^[A-Za-z0-9_-]{43}$/.test(data.write_token))throw new Error('bad receipt');
this.runId=data.run_id;this.token=data.write_token;
this.status(`Reporting enabled · test ${this.runId.slice(0,8)}`);
} catch {
this.available=false;this.status('Reporting unavailable · Save metrics remains available');
}
}
observe(receipt) {
if(this.terminal||receipt.elapsedMs-this.lastProgress<10000)return;
this.lastProgress=receipt.elapsedMs;this.queue('running',receipt);
}
finish(receipt) {
if(this.terminal)return;
this.terminal=true;this.queue(stopState(receipt.reason),receipt);
}
queue(state,receipt) {
// One latest snapshot, not a growing queue. Terminal state supersedes
// pending progress; a stopped run never becomes running again.
this.pending={state,metrics:reportMetrics(receipt)};
void this.flush();
}
async flush() {
if(this.busy)return;
this.busy=true;
try {
await this.ready;
if(!this.available){this.pending=null;return;}
while(this.pending) {
const report=this.pending;this.pending=null;
const payload={run_id:this.runId,sequence:++this.sequence,...report};
let delivered=false;
for(let attempt=0;attempt<2&&!delivered;attempt++) {
try {
const response=await this.request('/av-test/api/report',{
method:'POST',credentials:'omit',keepalive:true,
headers:{'Content-Type':'application/json',Authorization:`Bearer ${this.token}`},
body:JSON.stringify(payload),signal:AbortSignal.timeout(5000),
});
delivered=response.ok;
if(response.status>=400&&response.status<500)break;
} catch { /* No raw exception or endpoint response is displayed. */ }
}
if(report.state!=='running') {
this.status(delivered ? `Result reported · test ${this.runId.slice(0,8)}` :
'Result not reported · use Save metrics');
} else if(!delivered) {
this.status('Reporting interrupted · local test continues');
}
}
} finally {this.busy=false;}
}
}