${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.
`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}
${esc(photoHint)}
`;}
if(mode==='analyzing')return `
Timmy is looking at form and color…
Not symptoms. Not disease. Not whether Taco Bell was a strategic error.
`;
+ // An unreadable file is its own state, not a failed analysis: nothing was sent, nothing was
+ // attempted, and saying "couldn't analyze that safely" here implied an upload that never happened.
+ if(mode==='unreadable')return `
âš
That file isn’t a photo Timmy can read.
Nothing was uploaded. Choose a JPEG, PNG, or WebP photo, take a new one, or fill it out manually.
`;
if(mode==='error')return `
↻
Timmy couldn’t analyze that safely.
${esc(error||'Continue manually or try a clearer photo.')}
${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':mode==='unreadable'?'That photo could not be read':'Start with the camera'}
${photoFirstBody(mode,error)}`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||!visionReadiness(visionStatus).ready;if(analyze)analyze.onclick=runAiAnalysis;
document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')});
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)});
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)});
}
-async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
+async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{form.photoDataUrl='';photoHint='';showPhotoFirst('unreadable')}}
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;suggestionState=createSuggestionState(data);showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
function openLogger(){beginDraft();photoHint='';resetSuggestion();showLogStep(1)}
diff --git a/docs/PHOTO-DATAFLOW-THREAT-MODEL.md b/docs/PHOTO-DATAFLOW-THREAT-MODEL.md
index d7995fc..17df7e7 100644
--- a/docs/PHOTO-DATAFLOW-THREAT-MODEL.md
+++ b/docs/PHOTO-DATAFLOW-THREAT-MODEL.md
@@ -144,6 +144,26 @@ Each entry names the abuse case, the mitigation that is already committed or que
- **Testable follow-up:** #39
- **Evidence:** The production topology, secrets, backup, and rollback runbook, exercised against the deletion path so that purge reaches memory, temporary files, logs, and backups, with observability from #41 confirming no bytes reach the metrics surface.
+## Ingress controls implemented for #16
+
+`/api/analyze` is the only route that accepts image bytes, and every limit below is decided inside
+`server.mjs` before `analyzePhoto` runs, so a refusal never costs a provider call.
+
+| Control | Where | Behaviour |
+|---|---|---|
+| Per-caller request budget | `src/rate-limit.js` | Sliding window, 12 requests per 60s by default (`TIMMY_ANALYZE_RATE_LIMIT`, `TIMMY_ANALYZE_RATE_WINDOW_MS`); refusal is `429` with `Retry-After`. The key is a salted SHA-256 of the socket address held in process memory only — no address, no identity, no bytes are stored. |
+| Body ceiling and deadline | `src/http-body.js` | 6 MB ceiling and a 15s deadline (`TIMMY_BODY_TIMEOUT_MS`); `413` and `408` respectively, and the half-read socket is dropped after the refusal is written. |
+| Container sniffing | `src/image-ingress.js` | The declared data-URL MIME is treated as a claim, never evidence: JPEG/PNG/WebP is decided from magic bytes, and a mismatch is refused. |
+| Polyglot / trailing payloads | `src/image-ingress.js` | The container is walked to its own end marker (PNG `IEND`, JPEG `EOI`, RIFF length); anything appended after the image is refused instead of forwarded. |
+| Embedded metadata | `src/image-ingress.js` | JPEG APP1/APP13/COM segments and PNG `eXIf`/`tEXt`/`zTXt`/`iTXt`/`tIME` chunks are refused. The app's own upload is a canvas re-encode (JPEG q0.7, longest edge 1200px) and carries none, so the accepted path is already metadata-free. |
+| Pixel limits | `src/image-ingress.js` | Dimensions are read from the header (PNG `IHDR`, JPEG SOF, WebP `VP8X`/`VP8`/`VP8L`) and refused above a 4096px edge or 16 megapixels, so a decode bomb never reaches the worker. |
+| Log hygiene | `server.mjs` | Refusal copy is fixed prose; no request body, image bytes, hash, or address is written to a log or returned in an error. |
+
+Residual, and not claimed as solved here: the server refuses metadata-bearing or mismatched
+containers rather than transcoding them itself, so a client that cannot produce a clean re-encode
+must use the app's capture path. PNG chunk CRCs are not validated (structure is), and the request
+budget is per process, not per cluster.
+
## Accepted residual risks
- **Host compromise.** Data in a running process can be read by a sufficiently privileged attacker. This is accepted for the private beta and bounded by least privilege plus the purge procedure in #39; it is not claimed as eliminated.
@@ -154,4 +174,5 @@ Each entry names the abuse case, the mitigation that is already committed or que
- This document is enforced by `tests/threat-model.test.js`, which asserts that the diagram covers every byte-bearing component, that each required abuse case is a numbered threat with an owner and a tracked follow-up ticket, and that the model keeps the non-diagnostic boundary and ships no real medical images.
- The test runs in `npm test` and therefore inside the CI quality gate and the release build, so the threat model cannot silently drift from the code it describes.
+- The ingress controls above are enforced by `tests/image-ingress.test.js`, `tests/rate-limit.test.js`, `tests/http-body.test.js`, and `tests/analyze-ingress.acceptance.test.js`, which runs the shipping server against synthetic bytes and a counting provider stub; `tests/ingress-capture.acceptance.mjs` proves the same refusal in the running app. All of them run in `npm test` or the browser gate, so a regression fails the release instead of a promise.
- Boundary and boundary-adjacent claims are reviewed by the clinical and privacy gate (#22, #23) before beta; this model does not move that gate, it feeds it.
diff --git a/docs/RC-SECURITY-EVIDENCE.md b/docs/RC-SECURITY-EVIDENCE.md
index e1efb79..c39c632 100644
--- a/docs/RC-SECURITY-EVIDENCE.md
+++ b/docs/RC-SECURITY-EVIDENCE.md
@@ -59,13 +59,15 @@ contacts a real model provider, never uses a real medical image, and never touch
## Current state (read the run, not this line)
-The suite is expected to come back **BLOCKED** until the ingress hardening in
-[#16](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/16) lands: bytes
-are still trusted from the data-URL label, so an HTML file dressed as a JPEG reaches the worker and
-only fails afterwards on schema. That is the finding, and it is why the release gate treats a failing
-case as a block instead of a warning. Queue bounding is
-[#17](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/17) and is held
-here as P1.
+This section used to predict **BLOCKED** until the ingress hardening in
+[#16](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/16) landed. With
+that ingress in place the same command returns **GO** on this tree, because the format is decided from
+the bytes rather than the data-URL label: `ingress-polyglot-html` and `ingress-garbage-bytes` are now
+refused with `400` before the worker is called, and an oversized body is refused with a `413` the
+caller can read instead of a dropped connection. Latency for the load phase is measured against a
+second target with the request budget opened up, so those samples are analyses rather than `429`
+refusals. Queue bounding is still [#17](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/17)
+and is held here as P1.
Deletion is governed by the contribution ledger, not by this HTTP boundary; the case stays
`covered-elsewhere` against `tests/training-contribution.test.js` and blocks if that file disappears.
diff --git a/package.json b/package.json
index a5c31ad..d3cd0bb 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,11 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js tests/abstention-policy.test.js tests/release-security-gate.test.js",
+ "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js tests/abstention-policy.test.js tests/release-security-gate.test.js tests/image-ingress.test.js tests/rate-limit.test.js tests/http-body.test.js tests/analyze-ingress.acceptance.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
+ "test:ingress-capture": "node tests/ingress-capture.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:manual-fallback": "node tests/manual-fallback.acceptance.mjs",
"test:provisional": "node tests/provisional-suggestion.acceptance.mjs",
@@ -18,7 +19,7 @@
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
"test:photo-lifecycle": "node tests/photo-lifecycle.acceptance.mjs",
"test:training-contribution": "node tests/training-contribution.acceptance.mjs",
- "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check src/abstention-policy.js && node --check src/release-security-gate.js && node --check scripts/security_attack_suite.mjs && node --check tests/security-attack.acceptance.mjs && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/review_corpus.py scripts/dataset_card.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py scripts/operator_backup.py && node --check scripts/record_worker_benchmark.mjs && node --check scripts/abstention_policy_receipt.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
+ "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check src/abstention-policy.js && node --check src/release-security-gate.js && node --check scripts/security_attack_suite.mjs && node --check tests/security-attack.acceptance.mjs && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/review_corpus.py scripts/dataset_card.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py scripts/operator_backup.py && node --check scripts/record_worker_benchmark.mjs && node --check scripts/abstention_policy_receipt.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh && node --check src/image-ingress.js && node --check src/rate-limit.js && node --check src/http-body.js && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check tests/ingress-capture.acceptance.mjs && node --check tests/analyze-ingress.acceptance.test.js",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs",
diff --git a/scripts/build_release.py b/scripts/build_release.py
index 8245531..953ff39 100755
--- a/scripts/build_release.py
+++ b/scripts/build_release.py
@@ -50,6 +50,7 @@ DEMO_REQUIRED_CAPTIONS = (
"Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident",
"Every pattern line names the confirmed logs behind it, and following one shows exactly those records",
"Two logs is not a pattern: the summary refuses to describe a trend it cannot support",
+ "A file that only claims to be a photo is refused on the device — nothing is uploaded",
"The answer states the count and cites the four confirmed logs it counted",
"Following a citation opens exactly the confirmed log behind that answer",
"Ask something the journal cannot answer and Timmy refuses instead of reassuring",
@@ -217,6 +218,7 @@ def main() -> int:
run(["npm", "run", "test:ui"], tree)
run(["npm", "run", "test:photo"], tree)
run(["npm", "run", "test:mobile-capture"], tree)
+ run(["npm", "run", "test:ingress-capture"], tree)
run(["npm", "run", "test:sleek"], tree)
run(["npm", "run", "test:manual-fallback"], tree)
run(["npm", "run", "test:provisional"], tree)
@@ -275,7 +277,7 @@ def main() -> int:
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
raise SystemExit("Release demo contact sheet was not produced")
run(["npm", "audit", "--audit-level=high"], tree)
- for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "src/training-contribution.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs", "tests/training-contribution.acceptance.mjs"):
+ for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "src/training-contribution.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs", "tests/training-contribution.acceptance.mjs", "src/image-ingress.js", "src/rate-limit.js", "src/http-body.js", "tests/ingress-capture.acceptance.mjs"):
run(["node", "--check", file], tree)
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
@@ -392,6 +394,7 @@ def main() -> int:
"production_backup_restore": "passed",
"production_runbook_commands": "passed",
"portable_backup_restore": "passed",
+ "image_ingress_refusal": "passed",
"dependency_audit_high": "passed",
"syntax": "passed",
"secret_scan": "passed",
diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs
index 10ee935..8470719 100644
--- a/scripts/record_release_demo.mjs
+++ b/scripts/record_release_demo.mjs
@@ -90,7 +90,9 @@ const suggestionBody = {
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
};
let analyzeOverride = null;
+let analyzeCalls = 0;
await page.route('**/api/analyze', route => {
+ analyzeCalls += 1;
const response = analyzeOverride || { status: 200, body: suggestionBody };
return route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) });
});
@@ -310,6 +312,24 @@ await indicate('label[for="camera-photo"]');
await page.locator('#camera-photo').dispatchEvent('cancel');
await page.getByText(/Camera or photo picker closed/i).waitFor();
await caption('Camera closed cleanly — gallery and manual logging are still available', 1600);
+// Issue #16, on camera: the file below is committed as text and only claims to be a photo. It must
+// be refused on the device, the refusal must say nothing was uploaded, and the app must never post
+// it — asserted here, not narrated.
+await indicate('label[for="gallery-photo"]');
+await page.locator('#gallery-photo').setInputFiles('tests/fixtures/not-a-photo.jpg');
+await page.locator('[data-capture-error="unreadable"]').waitFor();
+const unreadableCopy = await page.locator('[data-capture-error="unreadable"]').innerText();
+if (!/isn’t a photo Timmy can read/.test(unreadableCopy) || !/Nothing was uploaded/.test(unreadableCopy)) {
+ throw new Error(`An unreadable file must be refused with the upload-free copy, saw: ${unreadableCopy}`);
+}
+if (await page.getByText(/couldn’t analyze that safely/i).count() !== 0) {
+ throw new Error('A file that never left the device must not be reported as a failed analysis');
+}
+if (analyzeCalls !== 0) throw new Error('An unreadable file was posted to /api/analyze');
+await caption('A file that only claims to be a photo is refused on the device — nothing is uploaded', 2600);
+await indicate('[data-capture-error="unreadable"]');
+await caption('Manual logging stays one tap away, and the photo picker is one tap back', 2000);
+await tap('#retake-photo', 450);
await indicate('label[for="gallery-photo"]');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await caption('Nothing uploads until explicit consent', 1100);
diff --git a/scripts/security_attack_suite.mjs b/scripts/security_attack_suite.mjs
index 8378c84..fa67461 100644
--- a/scripts/security_attack_suite.mjs
+++ b/scripts/security_attack_suite.mjs
@@ -60,7 +60,7 @@ function startStubWorker() {
// The real server, with its own stdout/stderr captured from the first byte: the log scan is only
// worth something if it read a log that actually exists.
-async function startTarget({ providerPort, port }) {
+async function startTarget({ providerPort, port, env = {} }) {
const child = spawn(process.execPath, ['server.mjs'], {
cwd: repoRoot,
env: {
@@ -72,6 +72,7 @@ async function startTarget({ providerPort, port }) {
TIMMY_VISION_BASE_URL: `http://127.0.0.1:${providerPort}/v1`,
TIMMY_VISION_MODEL: 'stub-vision-worker',
TIMMY_VISION_TIMEOUT_MS: '5000',
+ ...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -104,9 +105,9 @@ async function main() {
const photo = await readFile(join(repoRoot, 'tests/fixtures/synthetic-type4.jpg'));
const imageDataUrl = `data:image/jpeg;base64,${photo.toString('base64')}`;
- const post = async (body) => {
+ const post = async (body, targetBase = baseUrl) => {
const before = worker.state.requests;
- const response = await fetch(`${baseUrl}/api/analyze`, {
+ const response = await fetch(`${targetBase}/api/analyze`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
@@ -245,20 +246,35 @@ async function main() {
});
// Load: sequential valid analyses, abstaining answers, latency measured with the same boundary.
+ // The abuse cases above spend the ingress request budget, so latency is measured against a second
+ // target that starts with the budget wide open; otherwise these samples would be 429 refusals.
+ const loadPort = explicitBaseUrl ? null : await freePort();
+ const loadTarget = explicitBaseUrl
+ ? null
+ : await startTarget({
+ providerPort: worker.port,
+ port: loadPort,
+ env: { TIMMY_ANALYZE_RATE_LIMIT: '100000', TIMMY_ANALYZE_RATE_WINDOW_MS: '60000' },
+ });
+ const loadBase = explicitBaseUrl || `http://127.0.0.1:${loadPort}`;
worker.state.content = JSON.stringify({ isStool: false, confidence: 0.9 });
const samples = [];
let errors = 0;
let abstentions = 0;
- for (let index = 0; index < 8; index += 1) {
- const started = performance.now();
- const result = await post({ consent: true, imageDataUrl });
- samples.push(Math.round(performance.now() - started));
- if (result.status !== 200) errors += 1;
- else {
- try {
- if (JSON.parse(result.text).status === 'needs_user_input') abstentions += 1;
- } catch {}
+ try {
+ for (let index = 0; index < 8; index += 1) {
+ const started = performance.now();
+ const result = await post({ consent: true, imageDataUrl }, loadBase);
+ samples.push(Math.round(performance.now() - started));
+ if (result.status !== 200) errors += 1;
+ else {
+ try {
+ if (JSON.parse(result.text).status === 'needs_user_input') abstentions += 1;
+ } catch {}
+ }
}
+ } finally {
+ await stop(loadTarget?.child);
}
const percentiles = latencyPercentiles(samples);
const load = {
@@ -270,6 +286,12 @@ async function main() {
max: percentiles.max,
note: 'Latency measured against a counting stub on loopback; it measures this boundary, not a model.',
};
+ // Issue #16 adds a per-caller request budget at the ingress, so a suite that spends its budget on
+ // the abuse cases above would measure 429 refusals here and publish them as analysis latency.
+ // A refusal is not a sample: the load phase runs against its own permissive target instead.
+ if (load.errors > 0) {
+ throw new Error(`the load phase measured ${load.errors}/${load.requests} refusals instead of analyses, so it cannot publish latency`);
+ }
const report = buildReport({
generatedAt: new Date().toISOString(),
diff --git a/server.mjs b/server.mjs
index 8ce96f8..9c95cc4 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1,11 +1,15 @@
import http from 'node:http';
import { isIP } from 'node:net';
+import { createHash, randomBytes } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzePhoto, visionFailureResponse } from './src/vision-service.js';
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
+import { readJsonBody } from './src/http-body.js';
+import { inspectImageBytes, IngressError } from './src/image-ingress.js';
+import { createRateLimiter } from './src/rate-limit.js';
const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173);
@@ -32,7 +36,27 @@ const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${c
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&','<':'<','>':'>','"':'"'}[character]));}
-function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
+// Photo ingress (issue #16). Every limit here is decided from the request itself and fails before
+// any provider call: a per-caller request budget, a deadline for a body that never arrives, a byte
+// ceiling, and a container/pixel inspection of the decoded image. The bucket key is a salted hash
+// that lives only in this process's memory, so no address and no image byte is ever logged.
+const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000);
+const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)});
+const bucketSalt=randomBytes(16).toString('hex');
+function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);}
+function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});}
+function ingressGuard(payload){
+ const raw=String(payload?.imageDataUrl||'');
+ const match=/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/.exec(raw);
+ // A payload that is not shaped like a data URL is left to the semantic validator, so the user still
+ // gets the specific "upload a JPEG, PNG, or WebP photo" copy rather than a second opinion here.
+ if(!match)return null;
+ return inspectImageBytes(Buffer.from(match[2],'base64'),{declaredMime:match[1]});
+}
+function sendIngressError(res,error){
+ if(error instanceof IngressError)return sendJson(res,400,{error:error.message,code:error.code});
+ return sendJson(res,error.status||400,{error:error.message,code:error.code});
+}
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
@@ -52,8 +76,27 @@ http.createServer(async(req,res)=>{
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
}
if(appPath==='/api/analyze'&&req.method==='POST'){
+ // Refusals first, in cost order: the request budget, then the body, then the image bytes. None of
+ // these depend on the model, so hostile bytes are refused as hostile even when analysis is off —
+ // otherwise the disabled switch hides the ingress boundary behind a 503 and the refusal is untested.
+ const budget=analyzeLimiter.take(clientBucket(req));
+ if(!budget.allowed){
+ res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000))));
+ return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'});
+ }
+ let payload;
+ try{payload=await readJson(req)}
+ catch(error){
+ const sent=sendIngressError(res,error);
+ // The refusal is written first, then the half-read socket is dropped so a stalled body cannot
+ // hold a connection or its partial buffer open.
+ if(error?.code==='body_timeout'||error?.code==='too_large')req.destroy?.();
+ return sent;
+ }
+ try{ingressGuard(payload)}
+ catch(error){return sendIngressError(res,error)}
+ // Bytes are known to be a real photo at this point, so the disabled switch is the honest answer.
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
- const payload=await readJson(req);
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)}
}
diff --git a/src/http-body.js b/src/http-body.js
new file mode 100644
index 0000000..1d9492e
--- /dev/null
+++ b/src/http-body.js
@@ -0,0 +1,79 @@
+// Bounded request-body reading for the API boundary (issue #16).
+//
+// Three limits have to fail before any provider work starts: the byte ceiling, a deadline for a body
+// that never arrives, and JSON validity. All three refuse with a client-visible status and a fixed
+// message; none of them logs or returns the body.
+
+const COPY = Object.freeze({
+ too_large: [413, 'Request is too large.'],
+ invalid_json: [400, 'Invalid JSON request.'],
+ body_timeout: [408, 'The request took too long to arrive. Try again.'],
+});
+
+export class BodyError extends Error {
+ constructor(code) {
+ const [status, message] = COPY[code] || COPY.invalid_json;
+ super(message);
+ this.name = 'BodyError';
+ this.code = code;
+ this.status = status;
+ }
+}
+
+export function readJsonBody(req, { maxBytes = 6 * 1024 * 1024, timeoutMs = 15_000 } = {}) {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let tooLarge = false;
+ let size = 0;
+ const chunks = [];
+ const finish = callback => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(deadline);
+ req.removeListener('data', onData);
+ req.removeListener('end', onEnd);
+ req.removeListener('error', onError);
+ callback();
+ };
+ const deadline = setTimeout(() => {
+ finish(() => {
+ // Stop reading and let the caller answer 408; the server destroys the socket once the
+ // refusal is written, so a half-read body cannot be held open indefinitely.
+ req.pause?.();
+ reject(new BodyError('body_timeout'));
+ });
+ }, timeoutMs);
+
+ function onData(chunk) {
+ if (settled) return;
+ // Past the ceiling the body is already refused: keep draining it without buffering so the
+ // caller can still read the 413. Destroying the socket here would surface as "fetch failed"
+ // and leave the caller with no status to act on.
+ if (tooLarge) return;
+ size += chunk.length;
+ if (size > maxBytes) {
+ tooLarge = true;
+ chunks.length = 0;
+ return;
+ }
+ chunks.push(chunk);
+ }
+ function onEnd() {
+ finish(() => {
+ if (tooLarge) return reject(new BodyError('too_large'));
+ try {
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
+ } catch {
+ reject(new BodyError('invalid_json'));
+ }
+ });
+ }
+ function onError(error) {
+ finish(() => reject(error));
+ }
+
+ req.on('data', onData);
+ req.on('end', onEnd);
+ req.on('error', onError);
+ });
+}
diff --git a/src/image-ingress.js b/src/image-ingress.js
new file mode 100644
index 0000000..c904314
--- /dev/null
+++ b/src/image-ingress.js
@@ -0,0 +1,158 @@
+// Transport-level hardening for the one endpoint that accepts image bytes (issue #16).
+//
+// The declared MIME type in a data URL is a claim made by the caller, never evidence, so every
+// decision here comes from the bytes: the container is sniffed, the container is walked to its own
+// end marker so nothing can ride along behind a valid image, embedded metadata segments are refused
+// (the browser always uploads a canvas re-encode, which carries none), and the pixel dimensions are
+// read from the header so a decode bomb never reaches the worker.
+//
+// Refusal copy is fixed and deliberate: it names what is wrong with the file and never echoes the
+// bytes, a hash, or any part of the upload back to the caller or into a log line.
+
+export const MAX_EDGE = 4096;
+export const MAX_PIXELS = 16_000_000;
+
+const COPY = Object.freeze({
+ empty: 'The photo is empty.',
+ not_an_image: 'That file is not a JPEG, PNG, or WebP photo.',
+ container_mismatch: 'This photo is not the format it claims. Choose a JPEG, PNG, or WebP photo.',
+ truncated: 'This photo is incomplete or damaged. Choose another photo.',
+ trailing_data: 'This photo file has extra data after the image. Choose another photo.',
+ metadata_present: 'This photo still carries embedded metadata. Choose it through Timmy’s camera or gallery picker so it is re-encoded without metadata.',
+ dimensions_too_large: 'This photo is too large in pixels. Choose a smaller photo.',
+});
+
+export class IngressError extends Error {
+ constructor(code) {
+ super(COPY[code] || COPY.not_an_image);
+ this.name = 'IngressError';
+ this.code = code;
+ }
+}
+
+const refuse = code => {
+ throw new IngressError(code);
+};
+
+const PNG_CHUNKS = { metadata: new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']) };
+// Segment markers that carry metadata rather than image data: APP1 (Exif/XMP), APP13 (Photoshop
+// resource block) and COM (free-text comment). The app's own upload is a canvas re-encode and
+// contains none of them, so a file that does was not produced by the capture path.
+const JPEG_METADATA_MARKERS = new Set([0xe1, 0xed, 0xfe]);
+const JPEG_SOF_MARKERS = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
+
+function sniff(bytes) {
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png';
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
+ return '';
+}
+
+function inspectPng(bytes) {
+ let offset = 8;
+ let width = 0;
+ let height = 0;
+ let sawHeader = false;
+ while (offset + 8 <= bytes.length) {
+ const length = bytes.readUInt32BE(offset);
+ const type = bytes.toString('ascii', offset + 4, offset + 8);
+ const dataStart = offset + 8;
+ const next = dataStart + length + 4;
+ if (next > bytes.length) refuse('truncated');
+ if (!sawHeader) {
+ if (type !== 'IHDR' || length !== 13) refuse('truncated');
+ width = bytes.readUInt32BE(dataStart);
+ height = bytes.readUInt32BE(dataStart + 4);
+ sawHeader = true;
+ }
+ if (PNG_CHUNKS.metadata.has(type)) refuse('metadata_present');
+ if (type === 'IEND') {
+ if (next !== bytes.length) refuse('trailing_data');
+ return { width, height };
+ }
+ offset = next;
+ }
+ return refuse('truncated');
+}
+
+function inspectJpeg(bytes) {
+ let offset = 2;
+ let width = 0;
+ let height = 0;
+ while (offset + 4 <= bytes.length) {
+ if (bytes[offset] !== 0xff) refuse('truncated');
+ const marker = bytes[offset + 1];
+ if (marker === 0xd9) {
+ if (offset + 2 !== bytes.length) refuse('trailing_data');
+ break;
+ }
+ // Standalone markers carry no length field.
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0xff) {
+ offset += marker === 0xff ? 1 : 2;
+ continue;
+ }
+ const length = bytes.readUInt16BE(offset + 2);
+ if (length < 2 || offset + 2 + length > bytes.length) refuse('truncated');
+ if (JPEG_METADATA_MARKERS.has(marker)) refuse('metadata_present');
+ if (JPEG_SOF_MARKERS.has(marker) && length >= 7) {
+ height = bytes.readUInt16BE(offset + 5);
+ width = bytes.readUInt16BE(offset + 7);
+ }
+ offset += 2 + length;
+ if (marker === 0xda) {
+ // Entropy-coded scan data follows the start-of-scan header. The only thing left to trust is
+ // the end-of-image marker, and it has to be the last two bytes of the file.
+ if (bytes[bytes.length - 2] !== 0xff || bytes[bytes.length - 1] !== 0xd9) {
+ return refuse(bytes.includes(Buffer.from([0xff, 0xd9])) ? 'trailing_data' : 'truncated');
+ }
+ break;
+ }
+ }
+ if (!width || !height) refuse('truncated');
+ return { width, height };
+}
+
+function inspectWebp(bytes) {
+ const declared = bytes.readUInt32LE(4) + 8;
+ if (declared !== bytes.length) refuse(declared < bytes.length ? 'trailing_data' : 'truncated');
+ let offset = 12;
+ let width = 0;
+ let height = 0;
+ while (offset + 8 <= bytes.length) {
+ const type = bytes.toString('ascii', offset, offset + 4);
+ const size = bytes.readUInt32LE(offset + 4);
+ const dataStart = offset + 8;
+ if (dataStart + size > bytes.length) refuse('truncated');
+ if (type === 'VP8X' && size >= 10) {
+ width = bytes.readUIntLE(dataStart + 4, 3) + 1;
+ height = bytes.readUIntLE(dataStart + 7, 3) + 1;
+ } else if (type === 'VP8 ' && size >= 10) {
+ width = bytes.readUInt16LE(dataStart + 6) & 0x3fff;
+ height = bytes.readUInt16LE(dataStart + 8) & 0x3fff;
+ } else if (type === 'VP8L' && size >= 5) {
+ const bits = bytes.readUInt32LE(dataStart + 1);
+ width = (bits & 0x3fff) + 1;
+ height = ((bits >> 14) & 0x3fff) + 1;
+ }
+ offset = dataStart + size + (size % 2);
+ }
+ if (!width || !height) refuse('truncated');
+ return { width, height };
+}
+
+// Returns the real container, its pixel dimensions, and the byte count. Throws IngressError on
+// anything the model worker must never be handed.
+export function inspectImageBytes(bytes, { declaredMime = '' } = {}) {
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || []);
+ if (!buffer.length) refuse('empty');
+ const actual = sniff(buffer);
+ if (!actual) refuse('not_an_image');
+ if (declaredMime && declaredMime !== actual) refuse('container_mismatch');
+ const dimensions = actual === 'image/png' ? inspectPng(buffer)
+ : actual === 'image/jpeg' ? inspectJpeg(buffer)
+ : inspectWebp(buffer);
+ if (!dimensions.width || !dimensions.height) refuse('truncated');
+ if (dimensions.width > MAX_EDGE || dimensions.height > MAX_EDGE
+ || dimensions.width * dimensions.height > MAX_PIXELS) refuse('dimensions_too_large');
+ return { mime: actual, width: dimensions.width, height: dimensions.height, bytes: buffer.length };
+}
diff --git a/src/rate-limit.js b/src/rate-limit.js
new file mode 100644
index 0000000..b2b20a9
--- /dev/null
+++ b/src/rate-limit.js
@@ -0,0 +1,51 @@
+// Bounded, in-process request budget for the photo-analysis endpoint (issue #16).
+//
+// The limiter exists so a caller cannot make Timmy the transport for a flood of images to the
+// model worker. It keeps timestamps only, never the bytes, the file name, or the caller's address:
+// the server hands it a short-lived opaque bucket key. Refusals never extend a window, so a caller
+// cannot earn a longer penalty than the request it already made.
+
+export function createRateLimiter({ limit = 12, windowMs = 60_000, maxKeys = 2048, now = () => Date.now() } = {}) {
+ const hits = new Map();
+ let lastPrune = now();
+
+ function prune(at) {
+ const cutoff = at - windowMs;
+ for (const [key, stamps] of hits) {
+ const kept = stamps.filter(stamp => stamp > cutoff);
+ if (kept.length) hits.set(key, kept);
+ else hits.delete(key);
+ }
+ lastPrune = at;
+ }
+
+ return {
+ take(key) {
+ const at = now();
+ // Pruning is at most once per window, plus whenever the map outgrows its ceiling, so the
+ // bookkeeping stays bounded no matter how many distinct callers appear.
+ if (at - lastPrune >= windowMs || hits.size > maxKeys) prune(at);
+ const window = (hits.get(key) || []).filter(stamp => stamp > at - windowMs);
+ if (window.length >= limit) {
+ hits.set(key, window);
+ return { allowed: false, remaining: 0, retryAfterMs: Math.max(0, window[0] + windowMs - at) };
+ }
+ window.push(at);
+ hits.set(key, window);
+ if (hits.size > maxKeys) {
+ for (const oldest of hits.keys()) {
+ if (hits.size <= maxKeys) break;
+ hits.delete(oldest);
+ }
+ }
+ return { allowed: true, remaining: Math.max(0, limit - window.length), retryAfterMs: 0 };
+ },
+ size() {
+ return hits.size;
+ },
+ reset() {
+ hits.clear();
+ lastPrune = now();
+ },
+ };
+}
diff --git a/src/release-security-gate.js b/src/release-security-gate.js
index 6e17379..d3993ea 100644
--- a/src/release-security-gate.js
+++ b/src/release-security-gate.js
@@ -84,8 +84,8 @@ export function buildReport({ generatedAt, target, cases, load, suite = 'scripts
unproven: verdict.unproven,
coveredElsewhere: verdict.coveredElsewhere,
proven: verdict.proven,
- cases: verdict.cases.map(({ id, category, severity, requirement, expected, observed, status, mismatch, snippet, surface, evidence }) =>
- ({ id, category, severity, requirement, expected, observed, status, mismatch, surface, evidence, snippet: snippet ? redactImageBytes(snippet) : undefined })),
+ cases: verdict.cases.map(({ id, category, severity, requirement, expected, observed, status, mismatch, snippet, surface, evidence, evidenceVerified }) =>
+ ({ id, category, severity, requirement, expected, observed, status, mismatch, surface, evidence, evidenceVerified, snippet: snippet ? redactImageBytes(snippet) : undefined })),
load,
disclaimer: 'Synthetic hostile bytes only. Every case drives the real server against a stub worker; no real medical image and no live user data is involved.',
};
diff --git a/tests/analyze-ingress.acceptance.test.js b/tests/analyze-ingress.acceptance.test.js
new file mode 100644
index 0000000..498e111
--- /dev/null
+++ b/tests/analyze-ingress.acceptance.test.js
@@ -0,0 +1,245 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import http from 'node:http';
+import net from 'node:net';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { fileURLToPath } from 'node:url';
+
+// End-to-end proof for issue #16 at the real boundary: the server process that ships, not a mock of
+// it. Every image here is synthetic container bytes assembled in this file, and the provider is a
+// local stub that counts requests, so "the provider was never called" is measurable rather than
+// assumed.
+
+const repoRoot = fileURLToPath(new URL('..', import.meta.url));
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+function pngChunk(type, data = Buffer.alloc(0)) {
+ const length = Buffer.alloc(4);
+ length.writeUInt32BE(data.length);
+ return Buffer.concat([length, Buffer.from(type, 'ascii'), data, Buffer.alloc(4)]);
+}
+function cleanJpeg({ width = 1200, height = 900, tail = Buffer.alloc(0) } = {}) {
+ const sof = Buffer.alloc(17);
+ sof.writeUInt16BE(0xffc0, 0);
+ sof.writeUInt16BE(15, 2);
+ sof[4] = 8;
+ sof.writeUInt16BE(height, 5);
+ sof.writeUInt16BE(width, 7);
+ sof[9] = 3;
+ for (let i = 0; i < 3; i += 1) {
+ sof[10 + i * 3] = i + 1;
+ sof[11 + i * 3] = 0x11;
+ }
+ return Buffer.concat([Buffer.from([0xff, 0xd8]), sof, Buffer.from([0xff, 0xd9]), tail]);
+}
+function polyglotPng() {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(1200, 0);
+ ihdr.writeUInt32BE(900, 4);
+ ihdr[8] = 8;
+ ihdr[9] = 2;
+ return Buffer.concat([
+ PNG_SIGNATURE,
+ pngChunk('IHDR', ihdr),
+ pngChunk('IDAT', Buffer.from([0x78, 0x9c, 0x01])),
+ pngChunk('IEND'),
+ Buffer.from('', 'utf8'),
+ ]);
+}
+
+const dataUrl = (mime, bytes) => `data:${mime};base64,${bytes.toString('base64')}`;
+
+async function freePort() {
+ const server = net.createServer();
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ const { port } = server.address();
+ server.close();
+ return port;
+}
+
+async function startProvider() {
+ const state = { calls: 0 };
+ const server = http.createServer(async (req, res) => {
+ if (req.url === '/v1/models') {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ data: [{ id: 'ingress-test-model' }] }));
+ return;
+ }
+ state.calls += 1;
+ for await (const _ of req) { /* drain */ }
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ isStool: true, bristolType: 4, color: 'brown', confidence: 0.8,
+ imageQuality: 'good', observations: 'Synthetic test card.',
+ }),
+ },
+ }],
+ }));
+ });
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ return {
+ state,
+ port: server.address().port,
+ // fetch keeps connections alive, so a plain close() would wait for sockets the test already
+ // finished with and hang teardown.
+ close: () => new Promise(resolve => {
+ server.closeAllConnections?.();
+ server.close(resolve);
+ }),
+ };
+}
+
+async function withServer(env, run) {
+ const provider = await startProvider();
+ const port = await freePort();
+ const child = spawn(process.execPath, ['server.mjs'], {
+ cwd: repoRoot,
+ env: {
+ ...process.env,
+ HOST: '127.0.0.1',
+ PORT: String(port),
+ TIMMY_VISION_ENABLED: 'true',
+ TIMMY_VISION_PROFILE: 'selfhost',
+ TIMMY_VISION_BASE_URL: `http://127.0.0.1:${provider.port}/v1`,
+ TIMMY_VISION_MODEL: 'ingress-test-model',
+ ...env,
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ const logs = [];
+ child.stdout.on('data', chunk => logs.push(chunk.toString()));
+ child.stderr.on('data', chunk => logs.push(chunk.toString()));
+ const base = `http://127.0.0.1:${port}`;
+ try {
+ // Readiness is the server's own health route, not a sleep.
+ const deadline = Date.now() + 15_000;
+ for (;;) {
+ try {
+ const response = await fetch(`${base}/api/healthz`);
+ if (response.ok) break;
+ } catch { /* not up yet */ }
+ if (Date.now() > deadline) throw new Error(`server did not start: ${logs.join('')}`);
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ await run({ base, provider, logs });
+ } finally {
+ child.kill('SIGKILL');
+ await provider.close();
+ }
+}
+
+const postAnalyze = (base, payload) => fetch(`${base}/api/analyze`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+});
+
+test('the shipping server refuses bytes that are not an image before any provider work', async () => {
+ await withServer({}, async ({ base, provider }) => {
+ const response = await postAnalyze(base, {
+ consent: true,
+ imageDataUrl: dataUrl('image/jpeg', Buffer.from('', 'utf8')),
+ });
+ const body = await response.json();
+ assert.equal(response.status, 400);
+ assert.match(body.error, /not a JPEG, PNG, or WebP photo/i);
+ assert.equal(provider.state.calls, 0, 'a non-image must never reach the model provider');
+ });
+});
+
+test('the shipping server refuses a valid image with a hidden payload appended', async () => {
+ await withServer({}, async ({ base, provider, logs }) => {
+ const response = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/png', polyglotPng()) });
+ const body = await response.json();
+ assert.equal(response.status, 400);
+ assert.match(body.error, /extra data after the image/i);
+ assert.equal(provider.state.calls, 0, 'a polyglot file must never reach the model provider');
+ // The refusal path must not copy the upload into the process log, where it would outlive the request.
+ const logged = logs.join('');
+ assert.doesNotMatch(logged, /base64/i);
+ assert.doesNotMatch(logged, /[A-Za-z0-9+/]{120,}/, 'no run of image bytes may appear in the log');
+ });
+});
+
+test('the shipping server still analyses a clean re-encoded image', async () => {
+ await withServer({}, async ({ base, provider }) => {
+ const response = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) });
+ const body = await response.json();
+ assert.equal(response.status, 200);
+ assert.equal(body.status, 'suggestion');
+ assert.equal(provider.state.calls, 1, 'the clean path must reach the provider exactly once');
+ });
+});
+
+test('the shipping server refuses an oversized body with a refusal the caller can read', async () => {
+ // Issue #16 sets a byte ceiling, and issue #40 asks the release candidate to prove that ceiling
+ // behaves like a refusal rather than a dropped connection. Destroying the socket while the caller
+ // is still uploading leaves the caller with "fetch failed" and no status to act on, so the refusal
+ // has to be written and read before the connection goes away.
+ await withServer({}, async ({ base, provider }) => {
+ const oversized = JSON.stringify({
+ consent: true,
+ imageDataUrl: `data:image/jpeg;base64,${'A'.repeat(6.6 * 1024 * 1024)}`,
+ });
+ let response;
+ try {
+ response = await fetch(`${base}/api/analyze`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: oversized,
+ });
+ } catch (error) {
+ assert.fail(`an oversized body must be refused with a readable status, not a dropped connection: ${error.message}`);
+ }
+ const body = await response.json();
+ assert.equal(response.status, 413);
+ assert.match(body.error, /too large/i);
+ assert.equal(provider.state.calls, 0, 'an oversized body must never reach the provider');
+ });
+});
+
+test('the shipping server refuses a flood with 429 and stops calling the provider', async () => {
+ await withServer({ TIMMY_ANALYZE_RATE_LIMIT: '2', TIMMY_ANALYZE_RATE_WINDOW_MS: '60000' }, async ({ base, provider }) => {
+ const payload = { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) };
+ assert.equal((await postAnalyze(base, payload)).status, 200);
+ assert.equal((await postAnalyze(base, payload)).status, 200);
+ const refused = await postAnalyze(base, payload);
+ const body = await refused.json();
+ assert.equal(refused.status, 429);
+ assert.match(body.error, /too many photos|wait a minute/i);
+ assert.ok(Number(refused.headers.get('retry-after')) > 0, 'a refusal must say how long to wait');
+ assert.equal(provider.state.calls, 2, 'a refused request must not reach the provider');
+ });
+});
+
+test('the shipping server gives up on a body that never arrives', async () => {
+ await withServer({ TIMMY_BODY_TIMEOUT_MS: '250' }, async ({ base, provider }) => {
+ const port = Number(new URL(base).port);
+ const status = await new Promise((resolve, reject) => {
+ const socket = net.connect(port, '127.0.0.1', () => {
+ socket.write([
+ 'POST /api/analyze HTTP/1.1',
+ 'Host: 127.0.0.1',
+ 'Content-Type: application/json',
+ 'Content-Length: 400',
+ '', '',
+ ].join('\r\n'));
+ });
+ socket.setTimeout(10_000, () => { socket.destroy(); reject(new Error('no response before the client timeout')); });
+ socket.on('data', chunk => {
+ const line = chunk.toString('utf8').split('\r\n')[0];
+ socket.destroy();
+ resolve(Number(line.split(' ')[1]));
+ });
+ socket.on('error', error => { if (error.code !== 'ECONNRESET') reject(error); });
+ });
+ assert.equal(status, 408);
+ assert.equal(provider.state.calls, 0, 'a stalled body must never reach the provider');
+ });
+});
diff --git a/tests/fixtures/not-a-photo.jpg b/tests/fixtures/not-a-photo.jpg
new file mode 100644
index 0000000..38e40f0
--- /dev/null
+++ b/tests/fixtures/not-a-photo.jpg
@@ -0,0 +1 @@
+This file is named .jpg but it is not an image. Synthetic fixture for the image-ingress refusal path.
diff --git a/tests/http-body.test.js b/tests/http-body.test.js
new file mode 100644
index 0000000..998535a
--- /dev/null
+++ b/tests/http-body.test.js
@@ -0,0 +1,58 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { PassThrough } from 'node:stream';
+
+import { BodyError, readJsonBody } from '../src/http-body.js';
+
+const bodyStream = chunks => {
+ const stream = new PassThrough();
+ for (const chunk of chunks) stream.write(chunk);
+ stream.end();
+ return stream;
+};
+
+test('reads a bounded JSON request body', async () => {
+ const parsed = await readJsonBody(bodyStream([Buffer.from('{"consent":'), Buffer.from('true}')]), { maxBytes: 1024 });
+ assert.deepEqual(parsed, { consent: true });
+});
+
+test('refuses a body above the byte limit instead of buffering it', async () => {
+ await assert.rejects(
+ () => readJsonBody(bodyStream([Buffer.alloc(64, 0x61)]), { maxBytes: 16 }),
+ error => {
+ assert.ok(error instanceof BodyError);
+ assert.equal(error.code, 'too_large');
+ assert.equal(error.status, 413);
+ return true;
+ },
+ );
+});
+
+test('refuses malformed JSON with a client error', async () => {
+ await assert.rejects(
+ () => readJsonBody(bodyStream([Buffer.from('{not json')]), { maxBytes: 1024 }),
+ error => {
+ assert.ok(error instanceof BodyError);
+ assert.equal(error.code, 'invalid_json');
+ assert.equal(error.status, 400);
+ return true;
+ },
+ );
+});
+
+test('gives up on a body that never finishes and stops reading it', async () => {
+ const stalled = new PassThrough();
+ stalled.write(Buffer.from('{"consent":true'));
+ const started = Date.now();
+ await assert.rejects(
+ () => readJsonBody(stalled, { maxBytes: 1024, timeoutMs: 40 }),
+ error => {
+ assert.ok(error instanceof BodyError);
+ assert.equal(error.code, 'body_timeout');
+ assert.equal(error.status, 408);
+ return true;
+ },
+ );
+ assert.ok(Date.now() - started < 2_000, 'the deadline must fire promptly');
+ assert.equal(stalled.isPaused(), true, 'the stalled body must stop being read after the deadline');
+});
diff --git a/tests/image-ingress.test.js b/tests/image-ingress.test.js
new file mode 100644
index 0000000..24a791f
--- /dev/null
+++ b/tests/image-ingress.test.js
@@ -0,0 +1,175 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { IngressError, inspectImageBytes, MAX_EDGE, MAX_PIXELS } from '../src/image-ingress.js';
+
+// Every fixture built here is synthetic container bytes assembled in code. No photograph of any
+// person or specimen is used, and nothing is fetched.
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+function pngChunk(type, data = Buffer.alloc(0)) {
+ const length = Buffer.alloc(4);
+ length.writeUInt32BE(data.length);
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
+ // The parser walks chunk lengths to find the end of the container; the CRC is not read, so a
+ // synthetic zero CRC is enough to be structurally complete.
+ return Buffer.concat([length, body, Buffer.alloc(4)]);
+}
+
+function png({ width = 1200, height = 900, extraChunks = [], tail = Buffer.alloc(0) } = {}) {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(width, 0);
+ ihdr.writeUInt32BE(height, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 2; // truecolour
+ return Buffer.concat([
+ PNG_SIGNATURE,
+ pngChunk('IHDR', ihdr),
+ ...extraChunks,
+ pngChunk('IDAT', Buffer.from([0x78, 0x9c, 0x01])),
+ pngChunk('IEND'),
+ tail,
+ ]);
+}
+
+function jpeg({ width = 1200, height = 900, segments = [], tail = Buffer.alloc(0) } = {}) {
+ const sof = Buffer.alloc(17);
+ sof.writeUInt16BE(0xffc0, 0);
+ sof.writeUInt16BE(15, 2); // payload length
+ sof[4] = 8; // precision
+ sof.writeUInt16BE(height, 5);
+ sof.writeUInt16BE(width, 7);
+ sof[9] = 3; // component count
+ for (let i = 0; i < 3; i += 1) {
+ sof[10 + i * 3] = i + 1;
+ sof[11 + i * 3] = 0x11;
+ sof[12 + i * 3] = 0;
+ }
+ return Buffer.concat([
+ Buffer.from([0xff, 0xd8]),
+ ...segments,
+ sof,
+ Buffer.from([0xff, 0xd9]),
+ tail,
+ ]);
+}
+
+function exifSegment() {
+ const payload = Buffer.concat([
+ Buffer.from('Exif', 'ascii'),
+ Buffer.from([0x00, 0x00]),
+ Buffer.from('GPSLatitude=0.000', 'ascii'),
+ ]);
+ const header = Buffer.alloc(4);
+ header.writeUInt16BE(0xffe1, 0);
+ header.writeUInt16BE(payload.length + 2, 2);
+ return Buffer.concat([header, payload]);
+}
+
+function webp({ width = 1200, height = 900 } = {}) {
+ // VP8X canvas payload: flags(1) + reserved(3) + width-1(3, LE) + height-1(3, LE).
+ const payload = Buffer.alloc(10);
+ payload[0] = 0;
+ payload.writeUIntLE(width - 1, 4, 3);
+ payload.writeUIntLE(height - 1, 7, 3);
+ const chunk = Buffer.alloc(8);
+ chunk.write('VP8X', 0, 'ascii');
+ chunk.writeUInt32LE(payload.length, 4);
+ const body = Buffer.concat([Buffer.from('WEBP', 'ascii'), chunk, payload]);
+ const riff = Buffer.alloc(8);
+ riff.write('RIFF', 0, 'ascii');
+ riff.writeUInt32LE(body.length, 4);
+ return Buffer.concat([riff, body]);
+}
+
+const expectRefusal = (bytes, declaredMime, pattern) => {
+ assert.throws(
+ () => inspectImageBytes(bytes, { declaredMime }),
+ error => {
+ assert.ok(error instanceof IngressError, `expected an IngressError, got ${error?.name}`);
+ assert.match(error.message, pattern);
+ return true;
+ },
+ );
+};
+
+test('refuses a file whose bytes are not a JPEG, PNG, or WebP', () => {
+ expectRefusal(Buffer.from('', 'utf8'), 'image/jpeg', /not a JPEG, PNG, or WebP photo/i);
+});
+
+test('refuses a PNG that claims to be a JPEG', () => {
+ expectRefusal(png(), 'image/jpeg', /not the format it claims/i);
+});
+
+test('refuses a JPEG that claims to be a PNG', () => {
+ expectRefusal(jpeg(), 'image/png', /not the format it claims/i);
+});
+
+test('refuses a truncated container instead of forwarding a partial image', () => {
+ expectRefusal(PNG_SIGNATURE.subarray(0, 8), 'image/png', /incomplete or damaged/i);
+ expectRefusal(Buffer.from([0xff, 0xd8, 0xff, 0xe0]), 'image/jpeg', /incomplete or damaged/i);
+});
+
+test('refuses a PNG with bytes appended after its end marker', () => {
+ expectRefusal(png({ tail: Buffer.from('PK\u0003\u0004zip-appended', 'binary') }), 'image/png', /extra data after the image/i);
+});
+
+test('refuses a JPEG with bytes appended after its end marker', () => {
+ expectRefusal(jpeg({ tail: Buffer.from('', 'utf8') }), 'image/jpeg', /extra data after the image/i);
+});
+
+test('refuses a JPEG that still carries an EXIF segment', () => {
+ expectRefusal(jpeg({ segments: [exifSegment()] }), 'image/jpeg', /embedded metadata/i);
+});
+
+test('refuses a PNG that carries an eXIf chunk', () => {
+ const exifChunk = pngChunk('eXIf', Buffer.from('MM\u0000*metadata', 'binary'));
+ expectRefusal(png({ extraChunks: [exifChunk] }), 'image/png', /embedded metadata/i);
+});
+
+test('accepts a clean JPEG and reports its real dimensions', () => {
+ const intake = inspectImageBytes(jpeg({ width: 1200, height: 900 }), { declaredMime: 'image/jpeg' });
+ assert.equal(intake.mime, 'image/jpeg');
+ assert.equal(intake.width, 1200);
+ assert.equal(intake.height, 900);
+ assert.equal(intake.bytes, jpeg({ width: 1200, height: 900 }).length);
+});
+
+test('accepts a clean PNG and reports its real dimensions', () => {
+ const intake = inspectImageBytes(png({ width: 800, height: 600 }), { declaredMime: 'image/png' });
+ assert.equal(intake.mime, 'image/png');
+ assert.equal(intake.width, 800);
+ assert.equal(intake.height, 600);
+});
+
+test('accepts a clean WebP and reports its canvas dimensions', () => {
+ const intake = inspectImageBytes(webp({ width: 640, height: 480 }), { declaredMime: 'image/webp' });
+ assert.equal(intake.mime, 'image/webp');
+ assert.equal(intake.width, 640);
+ assert.equal(intake.height, 480);
+});
+
+test(`refuses an edge longer than ${MAX_EDGE} pixels even when the file is small`, () => {
+ expectRefusal(png({ width: MAX_EDGE + 1, height: 200 }), 'image/png', /too large in pixels/i);
+});
+
+test(`refuses an image above ${MAX_PIXELS} pixels when each edge fits`, () => {
+ const edge = 4096;
+ expectRefusal(jpeg({ width: edge, height: Math.ceil(MAX_PIXELS / edge) + 1 }), 'image/jpeg', /too large in pixels/i);
+});
+
+test('never repeats image bytes back to the caller in a refusal message', () => {
+ const marker = 'SECRETPIXELMARKER';
+ try {
+ inspectImageBytes(Buffer.from(`not an image ${marker}`, 'utf8'), { declaredMime: 'image/jpeg' });
+ assert.fail('expected a refusal');
+ } catch (error) {
+ assert.ok(error instanceof IngressError);
+ assert.doesNotMatch(error.message, new RegExp(marker));
+ assert.doesNotMatch(error.message, /[A-Za-z0-9+/=]{40,}/);
+ }
+});
+
+test('refuses an empty body with a bounded message', () => {
+ expectRefusal(Buffer.alloc(0), 'image/jpeg', /empty/i);
+});
diff --git a/tests/ingress-capture.acceptance.mjs b/tests/ingress-capture.acceptance.mjs
new file mode 100644
index 0000000..1a491d2
--- /dev/null
+++ b/tests/ingress-capture.acceptance.mjs
@@ -0,0 +1,70 @@
+import { chromium } from 'playwright';
+import assert from 'node:assert/strict';
+
+// Issue #16, as a user experiences it: a file that only claims to be a photo never becomes an upload,
+// never reaches the model worker, and still leaves manual logging one tap away. The second half of
+// this suite asks the real running server to refuse the same bytes, so the browser copy and the
+// server boundary are proven in the same run.
+
+const browser = await chromium.launch({ headless: true });
+const context = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ deviceScaleFactor: 2,
+ serviceWorkers: 'block',
+});
+const page = await context.newPage();
+let analysisRequests = 0;
+const analyzePayloads = [];
+await page.route('**/api/vision-status', route => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, modelSeen: true, model: 'synthetic-test-model' }),
+}));
+await page.route('**/api/analyze', route => {
+ analysisRequests += 1;
+ analyzePayloads.push(route.request().postData() || '');
+ return route.continue();
+});
+
+await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
+await page.locator('[data-scan]').click();
+
+const spoofed = Buffer.from('', 'utf8');
+await page.locator('#gallery-photo').setInputFiles({ name: 'photo.jpg', mimeType: 'image/jpeg', buffer: spoofed });
+
+const refusal = page.locator('[data-capture-error="unreadable"]');
+await refusal.waitFor();
+const copy = await refusal.innerText();
+assert.match(copy, /isn.t a photo Timmy can read/i, 'the refusal names what is wrong with the file');
+assert.match(copy, /Nothing was uploaded/i, 'the refusal states that nothing left the device');
+assert.equal(await page.getByText(/couldn.t analyze that safely/i).count(), 0,
+ 'an unreadable file must not be reported as a failed analysis');
+assert.equal(analysisRequests, 0, 'an unreadable file must never be posted to /api/analyze');
+assert.equal(await page.locator('#manual-from-scan').isVisible(), true, 'manual entry stays one tap away');
+assert.equal(await page.locator('#retake-photo').isVisible(), true, 'the picker stays reachable');
+
+// The same bytes, offered to the running server directly, are refused before any provider work.
+const serverResponse = await page.evaluate(async payload => {
+ const response = await fetch(`${document.querySelector('meta[name="timmy-base-path"]')?.content.replace(/\/$/, '') || ''}/api/analyze`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ return { status: response.status, body: await response.json() };
+}, { consent: true, imageDataUrl: `data:image/jpeg;base64,${spoofed.toString('base64')}` });
+assert.equal(serverResponse.status, 400, `the server must refuse non-image bytes, saw ${serverResponse.status}`);
+assert.match(serverResponse.body.error, /not a JPEG, PNG, or WebP photo/i);
+// Only this deliberate probe reached the endpoint; the app itself never posted the unreadable file.
+assert.equal(analysisRequests, 1, 'only the deliberate server probe may reach /api/analyze so far');
+assert.match(analyzePayloads[0], /"consent":true/);
+
+// A real (synthetic) photo still reaches the consent step, so the gate is a boundary and not a wall.
+await page.locator('#retake-photo').click();
+await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
+await page.locator('#ai-consent').waitFor();
+assert.equal(await page.locator('[data-capture-error="unreadable"]').count(), 0);
+assert.equal(analysisRequests, 1, 'a photo that is only selected is still not uploaded before consent');
+
+await context.close();
+await browser.close();
+console.log('PASS unreadable photo bytes are refused on device and at the server, with manual entry intact');
diff --git a/tests/mobile-capture.acceptance.mjs b/tests/mobile-capture.acceptance.mjs
index d704f29..3123852 100644
--- a/tests/mobile-capture.acceptance.mjs
+++ b/tests/mobile-capture.acceptance.mjs
@@ -47,8 +47,11 @@ for (const viewport of viewports) {
mimeType: 'image/jpeg',
buffer: Buffer.from('not an image'),
});
- assert.match(await page.locator('.scan-result').innerText(), /could not be read/i);
- await page.getByText('Try another photo', { exact: true }).click();
+ assert.match(await page.locator('.scan-result').innerText(), /isn.t a photo Timmy can read/i);
+ // Issue #16: the refusal is its own state and states plainly that nothing left the device, instead
+ // of borrowing the "couldn't analyze that safely" copy from a failed upload.
+ assert.match(await page.locator('.scan-result').innerText(), /Nothing was uploaded/i);
+ await page.getByText('Choose another photo', { exact: true }).click();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
assert.equal(await page.locator('#retake-photo').isVisible(), true);
diff --git a/tests/rate-limit.test.js b/tests/rate-limit.test.js
new file mode 100644
index 0000000..3652491
--- /dev/null
+++ b/tests/rate-limit.test.js
@@ -0,0 +1,91 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { createRateLimiter } from '../src/rate-limit.js';
+
+// A clock the test owns, so no test sleeps and every window boundary is exact.
+function manualClock(start = 1_700_000_000_000) {
+ let current = start;
+ return { now: () => current, advance: ms => { current += ms; } };
+}
+
+test('allows requests up to the limit and refuses the next one', () => {
+ const clock = manualClock();
+ const limiter = createRateLimiter({ limit: 3, windowMs: 60_000, now: clock.now });
+
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
+ const verdict = limiter.take('device-a');
+ assert.equal(verdict.allowed, true, `attempt ${attempt} must be allowed`);
+ assert.equal(verdict.remaining, 3 - attempt);
+ }
+
+ const refused = limiter.take('device-a');
+ assert.equal(refused.allowed, false);
+ assert.equal(refused.remaining, 0);
+ assert.equal(refused.retryAfterMs, 60_000);
+});
+
+test('counts each caller on its own budget', () => {
+ const clock = manualClock();
+ const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, now: clock.now });
+
+ assert.equal(limiter.take('device-a').allowed, true);
+ assert.equal(limiter.take('device-a').allowed, false);
+ assert.equal(limiter.take('device-b').allowed, true);
+});
+
+test('lets a caller back in once its window has passed', () => {
+ const clock = manualClock();
+ const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, now: clock.now });
+
+ assert.equal(limiter.take('device-a').allowed, true);
+ assert.equal(limiter.take('device-a').allowed, false);
+ clock.advance(60_001);
+ const allowedAgain = limiter.take('device-a');
+ assert.equal(allowedAgain.allowed, true);
+ assert.equal(allowedAgain.remaining, 0);
+});
+
+test('a refused request does not extend the block', () => {
+ const clock = manualClock();
+ const limiter = createRateLimiter({ limit: 1, windowMs: 1_000, now: clock.now });
+
+ assert.equal(limiter.take('device-a').allowed, true);
+ clock.advance(400);
+ assert.equal(limiter.take('device-a').allowed, false);
+ clock.advance(601);
+ // The original hit is now 1001ms old, so the window has moved on even though the caller hammered
+ // the endpoint in between. A refusal must never buy a longer penalty than the caller earned.
+ assert.equal(limiter.take('device-a').allowed, true);
+});
+
+test('reports the wait from the oldest hit still inside the window', () => {
+ const clock = manualClock();
+ const limiter = createRateLimiter({ limit: 2, windowMs: 10_000, now: clock.now });
+
+ limiter.take('device-a');
+ clock.advance(4_000);
+ limiter.take('device-a');
+ clock.advance(1_000);
+ const refused = limiter.take('device-a');
+ assert.equal(refused.allowed, false);
+ assert.equal(refused.retryAfterMs, 5_000);
+});
+
+test('keeps its caller map bounded when many callers appear', () => {
+ const clock = manualClock();
+ const limiter = createRateLimiter({ limit: 5, windowMs: 60_000, maxKeys: 8, now: clock.now });
+
+ for (let index = 0; index < 50; index += 1) limiter.take(`device-${index}`);
+ assert.ok(limiter.size() <= 8, `expected at most 8 tracked callers, saw ${limiter.size()}`);
+});
+
+test('prunes expired callers instead of growing for the life of the process', () => {
+ const clock = manualClock();
+ const limiter = createRateLimiter({ limit: 5, windowMs: 1_000, now: clock.now });
+
+ for (let index = 0; index < 20; index += 1) limiter.take(`device-${index}`);
+ clock.advance(1_001);
+ limiter.take('device-fresh');
+ assert.equal(limiter.size(), 1);
+});
diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js
index 16e8033..f384bfc 100644
--- a/tests/release-demo.test.js
+++ b/tests/release-demo.test.js
@@ -113,6 +113,23 @@ test('release demo visibly exercises the red-flag language coverage instead of o
assert.match(builder, /A tarry note already saved in the journal escalates the same way, before any provider call/);
});
+test('release demo shows a file that only claims to be a photo being refused before upload', async () => {
+ const demo = await readFile(demoPath, 'utf8');
+ const builder = await readFile(builderPath, 'utf8');
+
+ // Issue #16: the recording must show the device-side refusal of a non-image, prove nothing was
+ // posted, and keep manual entry reachable — all asserted in the browser, not narrated.
+ assert.match(demo, /tests\/fixtures\/not-a-photo\.jpg/);
+ assert.match(demo, /\[data-capture-error="unreadable"\]/);
+ assert.match(demo, /Nothing was uploaded/);
+ assert.match(demo, /An unreadable file was posted to \/api\/analyze/);
+ assert.match(demo, /A file that only claims to be a photo is refused on the device — nothing is uploaded/);
+ assert.match(demo, /Manual logging stays one tap away, and the photo picker is one tap back/);
+ // The caption is a required release gate, so a later release cannot drop the scene silently.
+ assert.match(builder, /A file that only claims to be a photo is refused on the device — nothing is uploaded/);
+ assert.match(builder, /"image_ingress_refusal": "passed"/);
+});
+
test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => {
const builder = await readFile(builderPath, 'utf8');
assert.match(builder, /"test:sleek"/);