No recording, uploads or saved camera/microphone content. Select the RCT's Lesavka video and microphone devices.
+
A/V Test
+
Select a camera and microphone. No recording or media uploads. Numeric timing/activity metrics and test status are reported automatically.
Stopped
+
Reporting starts with the test.
No observations.
-
Sync and capture-to-RCT freshness: not measured by this observer.
+
Audio/video sync and end-to-end delay: not measured by this check.
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.
+A separate paired-signal measurement is needed to verify synchronization.
diff --git a/services/bstein-dev-home/av-observer/ingress.yaml b/services/bstein-dev-home/av-observer/ingress.yaml
index 1f8fba01..c5788c8f 100644
--- a/services/bstein-dev-home/av-observer/ingress.yaml
+++ b/services/bstein-dev-home/av-observer/ingress.yaml
@@ -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:
diff --git a/services/bstein-dev-home/av-observer/kustomization.yaml b/services/bstein-dev-home/av-observer/kustomization.yaml
index d633d081..b2169498 100644
--- a/services/bstein-dev-home/av-observer/kustomization.yaml
+++ b/services/bstein-dev-home/av-observer/kustomization.yaml
@@ -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
diff --git a/services/bstein-dev-home/av-observer/metrics.js b/services/bstein-dev-home/av-observer/metrics.js
index 9598b9d9..e42fb1a0 100644
--- a/services/bstein-dev-home/av-observer/metrics.js
+++ b/services/bstein-dev-home/av-observer/metrics.js
@@ -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};
diff --git a/services/bstein-dev-home/av-observer/nginx.conf b/services/bstein-dev-home/av-observer/nginx.conf
index 04b1345f..e6b9cd3d 100644
--- a/services/bstein-dev-home/av-observer/nginx.conf
+++ b/services/bstein-dev-home/av-observer/nginx.conf
@@ -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; }
}
}
diff --git a/services/bstein-dev-home/av-observer/observer.js b/services/bstein-dev-home/av-observer/observer.js
index 5805bda1..368a0004 100644
--- a/services/bstein-dev-home/av-observer/observer.js
+++ b/services/bstein-dev-home/av-observer/observer.js
@@ -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'));
diff --git a/services/bstein-dev-home/av-observer/report-service/receipts.py b/services/bstein-dev-home/av-observer/report-service/receipts.py
new file mode 100644
index 00000000..b6508962
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/report-service/receipts.py
@@ -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})
diff --git a/services/bstein-dev-home/av-observer/report-service/server.py b/services/bstein-dev-home/av-observer/report-service/server.py
new file mode 100644
index 00000000..7f4074fa
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/report-service/server.py
@@ -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()
diff --git a/services/bstein-dev-home/av-observer/report-service/validation.py b/services/bstein-dev-home/av-observer/report-service/validation.py
new file mode 100644
index 00000000..6db6d89e
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/report-service/validation.py
@@ -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}
diff --git a/services/bstein-dev-home/av-observer/reporting.js b/services/bstein-dev-home/av-observer/reporting.js
new file mode 100644
index 00000000..59342a1a
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/reporting.js
@@ -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;}
+ }
+}