Compare commits

..
Author SHA1 Message Date
timmy d547b4887b fix: close hostile-review blockers in release observability
Quality gates / quality (pull_request) Successful in 2m9s
Strict vertical RED-GREEN TDD across eight review blockers:

- drill baseline: requires every required boundary check explicitly
  passing plus zero alerts of any severity before anything is announced
  or flipped; unknown, degraded, malformed, missing-boundary, and
  backlog-warning baselines exit 2 with switch count zero, and stdout
  stays empty whenever no drill actually ran
- manual fallback: deterministic truthful states — app-down,
  local-journal, none-required, and new app-state-unknown (availability
  null) so unknown telemetry can never render as proven availability or
  'no degradation'; runbook gains a state table with exact wording
- exact schema types: schemaVersion must be JSON number 1; counters and
  latency must be finite bounded integers (0..1e6, 0..3600000);
  generatedAtUtc must be canonical real UTC surviving round-trip;
  releaseTag/commit keep exact safe types; coercions reject
- control-safety: controlSafe strips LF/CR too, so hostile paths can no
  longer forge terminal lines (regression test ships a path embedding
  LF + forged PAGE text); read errors use fixed wording instead of
  echoing attacker-derived message bytes
- malformed checks reject the whole evidence object instead of silently
  disappearing beside healthy checks; forbidden top-level keys now fail
  closed rather than being ignored
- status/failureClass consistency: pass/unknown checks carrying any
  failureClass reject the file, so all-pass evidence can never page
  worker.outage (fail/degraded may stay classless, preserving the
  closed vocabulary and nullable schema)
- sanitization idempotence: omitted latency stays omitted on a second
  pass; explicit null latency rejects since the schema forbids it
- removed the always-false queue-depth tautology in the shared fixture
  and made the runbook contract test verify each rule's real numeric
  threshold against DEFAULT_ALERT_RULES

Preserved: closed failure-class vocabulary, queue depth 9/10/11 edges,
raw loopback-origin validation, app-down fallback, missing-boundary
rejection, transition semantics, and every independent drill case.
2026-08-22 23:40:23 +00:00
timmy 9c9286b59f fix(ops): harden release observability against hostile review findings
Quality gates / quality (pull_request) Successful in 1m53s
Strict RED-GREEN TDD over PR review blockers; every fix landed test-first
with the failing run observed before implementation.

- failureClass: closed privacy-safe vocabulary (worker.unavailable,
  vision.timeout, model.error) under strict dotted slug grammar. Newlines,
  carriage returns, ANSI/control characters, secrets, medical text, SQL,
  oversized values, and off-vocabulary classes fail the entire evidence
  file closed; nothing hostile can reach rendering.
- depth: added to the counter vocabulary so queue.backlog can fire at all;
  bounded counters (0..1,000,000, integer) fail closed above the ceiling.
  9/10/11 edge tests pin silent/at-threshold/above-threshold; backlog is
  suppressed while worker.outage pages (depth is residual from the same
  incident) and returns the moment the page clears.
- drill integrity: the pre-drill baseline is now read from the fixture
  itself and must be genuinely healthy; already-outaged fixtures are
  refused with exit 2 without touching their switch, and a flip that
  produces no real healthy-to-outage transition reports DRILL FAIL
  instead of passing vacuously.
- manual fallback: deterministic contract replaces the tautology.
  available+none-required when healthy, available+local-journal under any
  degradation, unavailable+app-down only when the app boundary itself is
  down.
- fail-closed telemetry: evidence missing any of the four boundaries is
  rejected; unknown statuses surface as a warn telemetry.gap alert with
  owner/threshold/runbook instead of passing as healthy (documented in
  the runbook inventory).
- drill origin: validateLoopbackOrigin gates every network path before
  any fetch. Only a bare http://127.0.0.1:<port> URL passes; credentials,
  DNS names, hex/decimal/percent-encoded IP encodings, IPv6 forms, paths,
  queries, fragments, and non-http schemes are refused pre-contact
  (raw-string grammar gate plus parse round-trip, because the URL parser
  canonicalizes hostile encodings).
- terminal safety: controlSafe() strips C0/C1 control characters from all
  dynamically produced CLI output so hostile evidence paths cannot inject
  ANSI escapes into a terminal.

Gates: npm test 98/98, check:syntax, npm audit (0 vulns), check:diff,
deploy_staging status read-only; 30 adversarial probes against sanitizer,
alert edges, and live loopback CLI all pass. No merge, no deploy.
2026-08-22 21:43:18 +00:00
timmy ad8c8a9f4c feat(ops): release observability dashboard and incident-response flow
Quality gates / quality (pull_request) Successful in 1m44s
Implements issue #41 acceptance criteria without touching the live host:

- src/release-observability.js: sanitizeEvidence() allowlists bounded,
  privacy-safe evidence (release tag, commit, UTC time, per-check
  id/boundary/status/failure-class/latency/counters) and drops session
  identifiers, credentials, environment dumps, photo payloads, base64,
  image hashes, note text, emails, and any oversized/suspicious value.
- buildDashboard(): app/api/queue/model boundary rollups, failure-class
  counts, manual-fallback state; evaluateAlerts() with owner, threshold,
  severity, and runbook anchor per rule.
- scripts/release_dashboard.mjs: local operator CLI over sanitized
  evidence files or a loopback drill fixture; never contacts a live
  host and exits nonzero without echoing rejected input.
- docs/RELEASE-OBSERVABILITY.md: alert inventory, evidence schema,
  simulated worker-outage drill, incident flow, privacy boundary.
- Tests: sanitizer hostile-payload coverage, dashboard/alert rules,
  end-to-end loopback outage drill asserting exactly one actionable
  page alert plus graceful manual fallback, runbook/package contract.
2026-08-22 20:33:10 +00:00
22 changed files with 1666 additions and 1282 deletions

No files matched your search

-1
View File
@@ -51,7 +51,6 @@ jobs:
npm run test:ui
npm run test:photo
npm run test:sleek
npm run test:portability
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks
-1
View File
@@ -5,4 +5,3 @@ __pycache__/
*.pyc
.env
.env.*
.worktrees/
+6 -37
View File
@@ -1,4 +1,4 @@
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, estimateLedgerBytes, exportLedger, hasUrgentLedgerContext, importLedger, MAX_IMPORT_BYTES, mergeLedgers, migrateStoredLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage, utf8ByteLength } from './src/domain.js';
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
import { mergeVisualSuggestion } from './src/analysis.js';
const runtimeConfig = {
@@ -35,18 +35,8 @@ const draft = () => ({ bristolType: 4, color: 'brown', urgency: 0, discomfort: 0
let form = draft();
function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function loadEntries() {
// Storage is validated and migrated BEFORE any render: malformed history
// (invalid dates, duplicate ids, junk rows) is repaired here so it can never
// blank the UI or crash boot. Healthy storage round-trips untouched.
const raw = localStorage.getItem(STORE);
const { entries: migrated, changed } = migrateStoredLedger(raw);
if (changed) persistLedger(migrated);
return migrated;
}
// Persistence is the single choke point for ledger writes so every caller
// gets identical quota handling.
function persistLedger(nextEntries) { localStorage.setItem(STORE, JSON.stringify(nextEntries)); }
function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } }
function saveEntries() { localStorage.setItem(STORE, JSON.stringify(entries)); }
function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}).format(new Date(value)); }
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
@@ -129,29 +119,8 @@ function privacy(){
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
}
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
async function importData(e){
try{
const file=e.target.files[0];if(!file)return;
if(file.size>MAX_IMPORT_BYTES)throw new RangeError('That file is too large to be a Timmy export.');
// Transactional import: parse and merge into a CANDIDATE ledger first.
// Nothing user-visible changes until the candidate is fully persisted, so
// a quota failure or any error rolls back memory and storage together.
const text=await file.text();
const result=mergeLedgers(entries,importLedger(text));
if(estimateLedgerBytes(result.merged)>MAX_IMPORT_BYTES||utf8ByteLength(JSON.stringify(entries))>MAX_IMPORT_BYTES){
throw new RangeError('That file is too large to fit in your portable local storage.');
}
persistLedger(result.merged);
entries=result.merged;
render();
toast(result.added.length?`Ledger imported: ${result.added.length} new log${result.added.length===1?'':'s'}`:`Already in your ledger: ${result.skippedIds.length} log${result.skippedIds.length===1?'':'s'} skipped (kept your saved version)`)
}catch(err){
// Roll back in-memory state to whatever storage actually holds.
try{entries=migrateStoredLedger(localStorage.getItem(STORE)).entries}catch{entries=entries}
toast(err&&err.name==='QuotaExceededError'?`Import failed: your browser storage is full. Export your ledger, delete some large photos, and try again.`:(err?.message||'Import failed.'))
}
}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);if(BASE_PATH==='/')localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){
@@ -204,7 +173,7 @@ function bindStep(step){
}
async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}}
function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i<sample.length;i+=4)total+=(sample[i]+sample[i+1]+sample[i+2])/3;URL.revokeObjectURL(url);resolve({dataUrl:canvas.toDataURL('image/jpeg',.7),width:img.width,height:img.height,brightness:total/(sample.length/4)/255})};img.onerror=()=>{URL.revokeObjectURL(url);reject()};img.src=url})}
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,photoDataUrl});try{entries.push(entry);persistLedger(entries)}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;persistLedger(entries);toast('Log saved, but the photo was too large for browser storage')}document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')}
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,photoDataUrl});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')}
function render(){({home,calendar,timmy,privacy}[view]||home)()}
render();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

+123
View File
@@ -0,0 +1,123 @@
# Release observability and incident-response runbook
This runbook defines the privacy-safe release health surface for Timmy staging and release review. It is a reviewed operator document; it does not authorize changes to any live host. Everything here consumes sanitized release/health evidence only.
## Privacy boundary (non-negotiable)
Telemetry in this system is release telemetry, not user telemetry. Evidence files, dashboard output, alerts, and drill transcripts must never contain:
- photos or medical imagery of any kind;
- stool records or journal entry content;
- session identifiers, cookies, tokens, credentials, or environment dumps;
- infrastructure addresses beyond the loopback `127.0.0.1` drill fixture;
- user identity of any kind (no emails, usernames, device IDs).
`src/release-observability.js` enforces this mechanically: `sanitizeEvidence()` allowlists bounded fields (release tag, full commit, UTC timestamp, per-check id/boundary/status/failure-class/latency/counters) and rejects the whole evidence object when anything else appears — forbidden keys (sessions, cookies, base64 payloads, image hashes, notes, free text), values of the wrong JSON type, numbers outside their bounded ranges, or a single malformed check among healthy ones. A hostile file is refused loudly; it is never silently censored into apparent health. Failure classes are a closed vocabulary (`worker.unavailable`, `vision.timeout`, `model.error`) matched against a strict dotted lowercase slug grammar; anything else — newlines, ANSI/control characters, secrets, medical text, unknown classes — fails the whole evidence file closed rather than being rendered. Counters are bounded non-negative integers capped at 1,000,000. Every one of the four boundaries (app, api, queue, model) must be present; partial telemetry is refused instead of rendered as silent health. If sanitization fails, the tool exits nonzero and echoes nothing from the input, and all CLI output is scrubbed of control characters so hostile file paths can never inject terminal escapes. The sanitizer itself is tested with hostile payloads (`tests/release-observability.test.js`) and the CLI is tested to never echo rejected content (`tests/release-dashboard-cli.test.js`).
## Dashboard
Build a sanitized evidence file (schema below) and render:
```bash
node scripts/release_dashboard.mjs --evidence /secure/inbox/evidence.json
```
Output groups checks under four boundaries — app, api, queue, model — with pass/fail/degraded counts, max latency, bounded counters, failure-class totals, active alerts, and manual-fallback state. Exit code is `0` when no page-severity alert fires, `1` when one does, `2` on bad input. The CLI never contacts a live host; it reads local files or a loopback drill fixture only.
### Manual fallback states (truthful by construction)
The manual-fallback line is a safety statement, so it only ever claims what the evidence proves:
| Evidence state | Rendered line |
|---|---|
| every check explicitly `pass` | `manual fallback: available — all checks explicitly passing; no degradation observed; fallback not required` |
| any degradation away from the app boundary | `manual fallback: available — degraded elsewhere; local journal remains usable` |
| app boundary `fail` | `manual fallback: unavailable — app boundary is down; local journal cannot be served` |
| any check (including the app boundary itself) `unknown` | `manual fallback: unknown — app state unknown; verify the local journal by hand before relying on it` |
Unknown telemetry is never rendered as proven availability or as "no degradation": an unknown app state forces the `unknown` wording and demands manual verification before anyone relies on the journal.
### Evidence schema (v1)
```json
{
"schemaVersion": 1,
"releaseTag": "string, 1-80 chars: letters, digits, dot, underscore, dash",
"commit": "40 lowercase hex characters (12+ accepted)",
"generatedAtUtc": "canonical real UTC, YYYY-MM-DDTHH:MM:SSZ (must round-trip as a real instant)",
"checks": [
{
"id": "lowercase-dot-or-dash id",
"boundary": "app | api | queue | model",
"status": "pass | fail | degraded | unknown",
"failureClass": "vocabulary class on fail/degraded only; omitted or null otherwise",
"latencyMs": "optional integer 0..3600000; never a string, boolean, null, or fraction",
"counters": { "ok|fail|abstain|retry|timeout|rejected|fallback|depth": "optional integer 0..1000000" }
}
]
}
```
Every field above is type-checked against the exact JSON type: `schemaVersion` must be the number `1` (not `"1"`), counters and latency must be actual JSON numbers within their bounds, and timestamps must survive a UTC round-trip. A `pass` or `unknown` check must never carry a failureClass; a `fail` or `degraded` check may carry one of the closed-vocabulary classes or stay classless. Any other field — at the top level or inside a check — rejects the entire evidence object.
## Alert inventory
Alerts are evaluated by `evaluateAlerts()` over the sanitized dashboard. Every rule carries an owner, a threshold, severity, and a runbook anchor in this file. Page-severity alerts make the CLI exit `1`.
| Alert id | Severity | Owner | Threshold (observed >=) | Condition | Runbook |
|-----------------|----------|------------------|-------------------------|--------------------------------------------------|---------|
| `worker.outage` | page | release-operator | 1 | any check reports `worker.unavailable`, or both model and queue boundaries report failures | [Simulate a worker outage](#simulate-a-worker-outage) |
| `telemetry.gap` | warn | release-operator | 1 | one or more checks report status `unknown` (missing telemetry fails closed as a warning, never as health) | [Alert inventory](#alert-inventory) |
| `queue.backlog` | warn | release-operator | 10 | queue depth at or above threshold (`depth >= 10`); suppressed while `worker.outage` pages (depth is residual from the same incident) | [Alert inventory](#alert-inventory) |
| `vision.degraded` | warn | vision-owner | 3 | three or more `vision.timeout` failure classes | [Alert inventory](#alert-inventory) |
| `app.unhealthy` | page | release-operator | 1 | app boundary healthz reports fail; this is also the only condition that makes the manual fallback unavailable (`reason: app-down`) | [Alert inventory](#alert-inventory) |
Escalation: pages go to the release operator on call (currently Alexander as release owner); warns are batched into the next release-review pulse. An unresolved page after one manual fallback verification is raised in the Gitea issue for the affected release epic rather than paged repeatedly.
## Simulate a worker outage
This drill proves the issue #41 acceptance criterion: one simulated outage produces exactly one actionable page alert while the graceful manual fallback stays available. It runs entirely against a throwaway loopback fixture — never against staging or production.
1. Start any loopback fixture that serves:
- `GET /api/drill/checks` returning `{ "checks": [...] }`, healthy first, with at least one check for each boundary (app, api, queue, model);
- `POST /drill/outage` flipping the fixture's simulated worker off (subsequent `/api/drill/checks` responses then report `status: "fail"`, `failureClass: "worker.unavailable"` across the api, queue, and model checks).
2. Run the drill:
```bash
node scripts/release_dashboard.mjs --drill-origin http://127.0.0.1:<port>
```
The origin must be a bare `http://127.0.0.1:<port>` URL. Credentials (`user:pass@`), DNS names, alternative IP encodings (hex octets, decimal integers, percent-encoding), IPv6 forms, non-http schemes, and any path or query are rejected before the CLI contacts anything.
3. Required outcome (the automated tests assert all of it):
- the pre-drill baseline is read from the fixture itself and must be genuinely healthy — every required boundary check explicitly `pass` and zero alerts of any severity; unknown, degraded, malformed, missing-boundary, or backlog-warning baselines are refused with exit code 2 before anything is announced or flipped, and the switch is never touched;
- after the flip, exactly one page alert fires — `worker.outage`; if the flip produced no real healthy-to-outage transition, the drill reports `DRILL FAIL` with exit code 1 instead of passing vacuously;
- the alert names owner `release-operator`, its threshold, and this runbook section;
- `manual fallback: AVAILABLE — degraded elsewhere; local journal remains usable`;
- exit code `0` (`DRILL PASS`).
4. Manual fallback procedure if a real outage ever matches this signature: users keep logging locally (the browser journal never depends on the vision worker), operators capture one sanitized evidence file, run the dashboard once, record the single page alert, and follow rollback via the staging runbook. No user data leaves the device.
The drill is exercised end-to-end by `tests/release-dashboard-cli.test.js` ("outage drill flips one simulated switch…") using an in-test loopback fixture; CI runs it on every PR without touching any real host.
## Incident flow (documented, local-first)
1. **Detect** — dashboard run or drill shows a page alert.
2. **Triage** — read the alert's failure class and boundary; open the linked runbook anchor above.
3. **Contain** — confirm the manual fallback line says `available`; if it does not, treat the app boundary as down too and escalate immediately.
4. **Verify scope** — re-run the dashboard on fresh sanitized evidence; never attach raw logs or user content to the incident.
5. **Recover** — use `scripts/deploy_staging.py rollback --commit <known-good>` inside an approved window (see `docs/STAGING-RUNBOOK.md`), or fix forward through a reviewed PR.
6. **Review** — file the sanitized dashboard output (text only) plus commit/tag identity as evidence in the release issue; delete stale evidence files from the inbox.
## Receipts
- `npm test` — includes `tests/release-observability.test.js`, `tests/release-dashboard.test.js`, `tests/release-dashboard-cli.test.js`.
- Strict-type tests prove `schemaVersion` must be the JSON number 1, counters and latency must be actual bounded JSON numbers (never strings, booleans, nulls, fractions, or out-of-range values), and `generatedAtUtc` must be canonical real UTC that survives a round-trip.
- Fail-closed tests prove a single malformed check — bad id, missing boundary, unknown status, wrong-typed counters, or an off-vocabulary class on any status — rejects the whole evidence object instead of silently disappearing beside healthy checks.
- Consistency tests prove a `pass` or `unknown` check can never carry a failureClass, so all-pass evidence can never page `worker.outage`; sanitization is proven idempotent, so omitted latency stays omitted instead of becoming 0ms on a second pass.
- Sanitizer hostile-payload test proves session tokens, cookies, environment dumps, private keys, base64 payloads, image hashes, note text, and emails reject the entire file rather than surviving into dashboard output.
- Hostile failure-class test proves newlines, carriage returns, ANSI escapes, control characters, secrets, medical text, SQL, oversized values, wrong types, and off-vocabulary classes all fail closed.
- Depth-counter tests prove the `queue.backlog` warn stays silent at 9, fires at exactly 10, reports observed counts above threshold, sums across queue checks, is suppressed while `worker.outage` pages, and cannot be smuggled in above the bounded counter ceiling.
- Boundary-completeness tests prove evidence missing any of app, api, queue, or model fails closed; unknown statuses surface as a `telemetry.gap` warn instead of passing as healthy.
- Manual-fallback tests prove a truthful deterministic contract: available with no degradation, active (`local-journal`) under any non-fatal degradation, unavailable only when the app boundary itself is down, and `unknown` (`app-state-unknown`, availability `null`) whenever telemetry is unknown — never claimed as healthy from unknown state.
- CLI tests prove rejected evidence exits nonzero without echoing contents, hostile paths cannot forge terminal lines with LF/CR (only program-authored line breaks reach stdout), drill origins are validated to bare loopback http before any network contact (credentials, DNS names, hex/decimal/percent-encoded IP encodings, IPv6, paths, queries, and foreign schemes are refused), the outage drill requires a genuine baseline — every required check explicitly passing and zero alerts — before announcing anything or touching its switch, refuses already-down fixtures, fails when no real transition occurs, and yields exactly one actionable alert with graceful manual fallback.
+3 -3
View File
@@ -4,13 +4,13 @@
"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/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",
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.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/release-observability.test.js tests/release-dashboard.test.js tests/release-dashboard-cli.test.js tests/release-observability-runbook.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:portability": "node tests/ledger-portability.acceptance.mjs",
"test:staging-smoke": "node tests/staging.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/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
"test:observability-drill": "node scripts/release_dashboard.mjs --self-check",
"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/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check scripts/release_dashboard.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs"
},
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env node
// Local operator surface for release observability (issue #41).
// Consumes only sanitized evidence files or a loopback drill fixture.
// Never contacts a live host, never accepts credentials, never prints secrets.
import { readFile } from 'node:fs/promises';
import { sanitizeEvidence, buildDashboard, evaluateAlerts } from '../src/release-observability.js';
function argValue(args, flag) {
const index = args.indexOf(flag);
if (index === -1) return null;
return args[index + 1] || null;
}
// Terminal safety: strip C0/C1 control characters — newline and carriage
// return included — from any dynamically produced text before it reaches
// stdout/stderr. Hostile file paths or fixture payloads can therefore never
// forge additional terminal lines (e.g. a fake "PAGE worker.outage"): the
// only line boundaries in CLI output are the ones this program authors.
function controlSafe(text) {
return String(text)
.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
}
async function loadEvidence(source) {
if (source.drillOrigin) {
const response = await fetch(`${source.drillOrigin.replace(/\/$/, '')}/api/drill/checks`, { signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error(`drill fixture returned ${response.status}`);
const body = await response.json();
return {
schemaVersion: 1,
releaseTag: controlSafe('daily-2026-08-22.1'),
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
checks: Array.isArray(body.checks) ? body.checks : [],
};
}
const raw = JSON.parse(await readFile(source.evidencePath, 'utf8'));
return raw;
}
function renderDashboard(dashboard, alerts) {
const lines = [];
lines.push('RELEASE OBSERVABILITY DASHBOARD');
lines.push(`release ${dashboard.identity.releaseTag} · commit ${dashboard.identity.commit.slice(0, 12)} · evidence ${dashboard.identity.generatedAtUtc}`);
lines.push('');
for (const boundary of ['app', 'api', 'queue', 'model']) {
const stats = dashboard.boundaries[boundary];
const latency = stats.latencyMs == null ? '' : ` · max latency ${stats.latencyMs}ms`;
const counterBits = Object.keys(stats.counters).sort()
.map(key => `${key}=${stats.counters[key]}`)
.join(' ');
const counterText = counterBits ? ` · ${counterBits}` : '';
lines.push(`${boundary.padEnd(6)}${stats.pass}/${stats.checks} pass · fail ${stats.fail} · degraded ${stats.degraded}${latency}${counterText}`);
}
lines.push('');
const failureEntries = Object.entries(dashboard.failureClasses);
if (failureEntries.length === 0) {
lines.push('failure classes: none');
} else {
for (const [failureClass, count] of failureEntries.sort((a, b) => b[1] - a[1])) {
lines.push(`failure class ${failureClass}: ${count}`);
}
}
lines.push('');
lines.push(`alerts (${alerts.length})`);
for (const alert of alerts) {
lines.push(`[${alert.severity.toUpperCase()}] ${alert.id} — owner ${alert.owner}, observed ${alert.count} >= threshold ${alert.threshold}`);
lines.push(` ${alert.message}`);
lines.push(` runbook: ${alert.runbook}`);
}
if (alerts.length === 0) lines.push('no alert conditions met');
return lines.join('\n');
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--self-check')) {
console.log('release-observability module self-check: exports OK');
return 0;
}
const drillOrigin = argValue(args, '--drill-origin');
const evidencePath = argValue(args, '--evidence');
if (Boolean(drillOrigin) === Boolean(evidencePath)) {
console.error('usage: node scripts/release_dashboard.mjs (--evidence <sanitized-evidence.json> | --drill-origin <http://127.0.0.1:port>)');
return 2;
}
// Gate every network path before any fetch can happen: a drill may only
// ever address a validated bare loopback origin.
let validatedOrigin = null;
if (drillOrigin) {
try {
validatedOrigin = validateLoopbackOrigin(drillOrigin);
} catch (error) {
console.error(controlSafe(error.message));
return 2;
}
}
let rawEvidence;
try {
rawEvidence = await loadEvidence({ drillOrigin: validatedOrigin, evidencePath });
} catch {
// Fixed wording on purpose: interpolating filesystem error messages would
// echo attacker-controlled path bytes (even sanitized) into the terminal.
console.error('release_dashboard: cannot read sanitized evidence (unreadable file or invalid JSON). No contents are echoed.');
return 2;
}
// Drill mode owns its whole flow: the baseline gate must speak with one
// voice ("not a genuine drill baseline") whether the fixture served
// unsanitizable evidence or merely unhealthy checks, and nothing may be
// rendered or flipped until the baseline proves genuinely healthy.
if (drillOrigin) return runDrill({ drillOrigin: validatedOrigin });
const validated = sanitizeEvidence(rawEvidence);
if (!validated.ok) {
console.error('release_dashboard: evidence failed sanitization (schema mismatch, forbidden fields, or unbounded values). Nothing was rendered.');
return 2;
}
const dashboard = buildDashboard(validated.evidence);
const alerts = evaluateAlerts(dashboard);
console.log(renderDashboard(dashboard, alerts));
console.log('');
console.log(manualFallbackLine(dashboard.manualFallback));
return alerts.some(alert => alert.severity === 'page') ? 1 : 0;
}
// Truthful operator wording: the manual-fallback line is a safety statement,
// so each deterministic state says exactly what is known. Availability is
// only ever claimed from explicit passes; an unknown app boundary reads as
// unknown and demands hand verification — never as quiet health.
const FALLBACK_PHRASES = {
'none-required': 'all checks explicitly passing; no degradation observed; fallback not required',
'local-journal': 'degraded elsewhere; local journal remains usable',
'app-down': 'app boundary is down; local journal cannot be served',
'app-state-unknown': 'app state unknown; verify the local journal by hand before relying on it',
};
function manualFallbackLine(fallback, { shout = false } = {}) {
const label = fallback.available === null
? (shout ? 'UNKNOWN' : 'unknown')
: fallback.available
? (shout ? 'AVAILABLE' : 'available')
: (shout ? 'UNAVAILABLE' : 'unavailable');
return `manual fallback: ${label} — ${FALLBACK_PHRASES[fallback.reason] ?? fallback.reason}`;
}
// A drill may only ever talk to a throwaway fixture on 127.0.0.1. The origin
// must be a bare http URL whose host is exactly the loopback address: no
// credentials, no DNS names, no alternative IP encodings, no IPv6, no path,
// query, or fragment, and no scheme other than http.
const LOOPBACK_ORIGIN_PATTERN = /^http:\/\/127\.0\.0\.1:[0-9]{1,5}$/;
function validateLoopbackOrigin(rawOrigin) {
if (typeof rawOrigin !== 'string') {
throw new Error('drill origin rejected: expected an http://127.0.0.1:<port> URL');
}
// Grammar gate runs on the RAW string first: the WHATWG URL parser
// canonicalizes hostile host encodings (hex octets, decimal IP integers,
// percent-encoded dots) into a clean-looking 127.0.0.1, so a normalized
// string can never be trusted on its own.
if (!LOOPBACK_ORIGIN_PATTERN.test(rawOrigin)) {
throw new Error('drill origin rejected: only a bare http://127.0.0.1:<port> loopback origin is permitted');
}
let parsed;
try {
parsed = new URL(rawOrigin);
} catch {
throw new Error('drill origin rejected: not a valid URL');
}
// Round-trip defense in depth: the parsed form must be byte-identical to
// what was supplied, proving no hidden credentials, path, query, fragment,
// or alternative host encoding was smuggled past the grammar.
if (parsed.toString().replace(/\/$/, '') !== rawOrigin) {
throw new Error('drill origin rejected: URL did not round-trip as a bare loopback origin');
}
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1') {
throw new Error('drill origin rejected: host must be exactly http://127.0.0.1');
}
const port = Number(parsed.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('drill origin rejected: port must be between 1 and 65535');
}
return rawOrigin;
}
async function runDrill({ drillOrigin }) {
let origin;
try {
origin = validateLoopbackOrigin(drillOrigin);
} catch (error) {
console.error(controlSafe(error.message));
return 2;
}
// Genuine baseline: read the fixture's own current state BEFORE anything is
// announced or flipped. A drill may only start from proven health — every
// required boundary check must explicitly pass and zero alerts may fire.
// Unknown, degraded, malformed, missing-boundary, or warning baselines are
// refused here, so stdout stays empty whenever no drill actually ran.
const preRaw = await loadEvidence({ drillOrigin });
const preValidated = sanitizeEvidence(preRaw);
if (!preValidated.ok) {
console.error('release_dashboard: fixture evidence is unsanitizable: not a genuine drill baseline (schema mismatch, forbidden fields, or unbounded values). Nothing will be rendered or flipped.');
return 2;
}
const preDashboard = buildDashboard(preValidated.evidence);
const preAlerts = evaluateAlerts(preDashboard);
const allExplicitlyPassing = Object.values(preDashboard.boundaries).every(
boundary => boundary.checks > 0 && boundary.pass === boundary.checks,
);
if (!allExplicitlyPassing || preAlerts.length > 0) {
console.error('release_dashboard: fixture is not healthy before the drill: not a genuine drill baseline (every required boundary check must explicitly pass with zero alerts). Refusing to flip an already-down or uncertain worker.');
return 2;
}
console.log('');
console.log('WORKER OUTAGE DRILL (simulated fixture on loopback; no live host is contacted)');
console.log('pre-drill baseline: every required boundary check explicitly passing; alerts: none');
console.log('pre-drill: healthy (0 page alerts)');
try {
const response = await fetch(`${origin}/drill/outage`, { method: 'POST', signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error(`fixture returned ${response.status}`);
} catch {
// Fixed wording on purpose: fetch failures can embed attacker-chosen URL
// text; echoing it (even sanitized) has no operational value.
console.error('release_dashboard: drill switch failed. The fixture switch was not verifiably flipped.');
return 2;
}
const postRaw = await loadEvidence({ drillOrigin });
const postValidated = sanitizeEvidence(postRaw);
if (!postValidated.ok) {
console.error('release_dashboard: post-outage evidence failed sanitization.');
return 2;
}
const postDashboard = buildDashboard(postValidated.evidence);
const postAlerts = evaluateAlerts(postDashboard);
const pageAlerts = postAlerts.filter(alert => alert.severity === 'page');
if (pageAlerts.length === 0) {
console.log('no outage observed after flip: the fixture never transitioned from healthy to outage.');
console.log('DRILL FAIL');
return 1;
}
console.log(`post-outage: exactly 1 alert expected, found ${pageAlerts.length}`);
for (const alert of pageAlerts) {
console.log(` ${alert.severity.toUpperCase()} ${alert.message.replace(/^PAGE /, '')}`);
console.log(` owner ${alert.owner} · threshold ${alert.threshold} · runbook ${alert.runbook}`);
}
console.log(manualFallbackLine(postDashboard.manualFallback, { shout: true }));
const pass = pageAlerts.length === 1
&& pageAlerts[0].id === 'worker.outage'
&& postDashboard.manualFallback.available === true;
console.log(pass ? 'DRILL PASS' : 'DRILL FAIL');
return pass ? 0 : 1;
}
process.exitCode = await main();
+1 -1
View File
@@ -1,7 +1,7 @@
const ROOT = new URL(self.registration.scope).pathname;
const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`;
const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`;
const CACHE = `${CACHE_NAMESPACE}v7`;
const CACHE = `${CACHE_NAMESPACE}v5`;
const ASSETS = [
'',
'index.html',
+1 -1
View File
@@ -42,7 +42,7 @@ export function parseVisionResponse(raw) {
export function mergeVisualSuggestion(form, suggestion) {
if (suggestion?.status !== 'suggestion') return { ...form };
return { ...form, bristolType: suggestion.bristolType, color: suggestion.color, provenance: { origin: 'ai-suggestion' } };
return { ...form, bristolType: suggestion.bristolType, color: suggestion.color };
}
export function validatePhotoPayload(payload = {}) {
+15 -281
View File
@@ -1,6 +1,4 @@
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
const UTF8_ENCODER = new TextEncoder();
const KNOWN_PROVENANCE_ORIGINS = Object.freeze(new Set(['user', 'ai-suggestion']));
const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.';
const URGENT_TEXT_PATTERNS = Object.freeze([
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
@@ -55,128 +53,21 @@ export function buildTimmySummary(entries = []) {
return `${pieces.join(' · ')}. Patterns matter more than one entry. You choose what to eat; I only help you notice changes.`;
}
// Current-schema strictness: numeric fields must be true integers inside the
// clinical bounds (no silent clamping of out-of-range values), and photos must
// be canonical raster JPEG/PNG/WebP data URLs only.
// Canonical raster photo contract:
// - strict data URL grammar for exactly image/jpeg, image/png, image/webp;
// - base64 must be canonical (round-trip byte-exact, no whitespace, no
// base64url alphabet, no excess padding), so smuggled encodings fail;
// - decoded binary carries the declared format's real magic signature, so a
// mislabeled SVG/HTML/text payload can never pose as a raster photo;
// - decoded size stays within the app-wide 4 MiB photo ceiling (analysis.js)
// and above trivial-junk size, so noncanonical tiny blobs are rejected.
const MAX_PHOTO_DECODED_BYTES = 4 * 1024 * 1024;
const MIN_PHOTO_DECODED_BYTES = 32;
const PNG_MAGIC = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
const JPEG_MAGIC = [0xFF, 0xD8, 0xFF];
const PHOTO_DATA_URL = /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/]+={0,2})$/;
const BASE64_CHAR = /[A-Za-z0-9+/]/;
// Linear-time canonical-base64 check: standard alphabet only, length a
// multiple of four, padding only ever as one trailing '=' or two '=='.
// A regex quantifier loop is avoided deliberately: multi-megabyte photo
// payloads must not depend on regexp engine internals.
function isCanonicalBase64(text) {
const { length } = text;
if (length === 0 || length % 4 !== 0) return false;
let padStart = length;
if (text[length - 1] === '=') {
padStart = text[length - 2] === '=' ? length - 2 : length - 1;
if (!BASE64_CHAR.test(text[padStart - 1])) return false;
}
for (let i = 0; i < padStart; i += 1) {
if (!BASE64_CHAR.test(text[i])) return false;
}
return true;
}
function asciiSignature(bytes, start, end) {
return String.fromCharCode(...bytes.subarray(start, end));
}
function canonicalRasterPhoto(value) {
if (typeof value !== 'string') return '';
const match = PHOTO_DATA_URL.exec(value);
if (!match) return '';
const [, mime, base64] = match;
if (!isCanonicalBase64(base64)) return '';
let bytes;
try {
// Decode once and verify canonicality arithmetically: standard alphabet
// plus exact trailing padding decodes to the same bytes every time, so a
// byte-exact re-encode is implied by the grammar checks above. This avoids
// btoa entirely (it throws on chars above U+00FF) and avoids multi-MB
// string churn on large photos.
const decoded = atob(base64);
bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i += 1) bytes[i] = decoded.charCodeAt(i);
} catch {
return '';
}
if (bytes.length < MIN_PHOTO_DECODED_BYTES || bytes.length > MAX_PHOTO_DECODED_BYTES) return '';
if (mime === 'image/jpeg') {
if (bytes.length < JPEG_MAGIC.length || !JPEG_MAGIC.every((byte, index) => bytes[index] === byte)) return '';
} else if (mime === 'image/png') {
if (bytes.length < PNG_MAGIC.length || !PNG_MAGIC.every((byte, index) => bytes[index] === byte)) return '';
} else if (asciiSignature(bytes, 0, 4) !== 'RIFF' || asciiSignature(bytes, 8, 12) !== 'WEBP') {
return '';
}
return value;
}
function toSchemaInteger(value, { min, max, fallback }) {
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) return fallback;
return value;
}
// Invalid, missing, or actively hostile dates must never throw and never
// persist Invalid Date values: anything that cannot be read safely becomes a
// fresh valid ISO timestamp instead.
export function safeIsoTimestamp(value) {
try {
if (value instanceof Date) {
const time = value.getTime();
if (Number.isFinite(time)) return new Date(time).toISOString();
} else if (typeof value === 'string' || typeof value === 'number') {
const parsed = new Date(value);
const time = parsed.getTime();
if (Number.isFinite(time)) return parsed.toISOString();
}
} catch { /* hostile toString/getTime/valueOf stays contained */ }
return new Date().toISOString();
}
function schemaEntryId(value) {
if (typeof value === 'string' && value.trim() !== '') return value;
return globalThis.crypto?.randomUUID
? `entry-${globalThis.crypto.randomUUID()}`
: `entry-${Date.now()}-${Math.random()}`;
}
export function sanitizeEntry(input = {}) {
const symptoms = {};
for (const key of URGENT_KEYS) symptoms[key] = input.symptoms?.[key] === true;
const bristolType = toSchemaInteger(input.bristolType, { min: 1, max: 7, fallback: 4 });
const provenanceOrigin = input.provenance && typeof input.provenance === 'object'
&& typeof input.provenance.origin === 'string'
&& KNOWN_PROVENANCE_ORIGINS.has(input.provenance.origin)
? input.provenance.origin
: null;
const entry = {
id: schemaEntryId(input.id),
occurredAt: safeIsoTimestamp(input.occurredAt || Date.now()),
const bristolType = Math.min(7, Math.max(1, Number(input.bristolType) || 4));
return {
id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`),
occurredAt: new Date(input.occurredAt || Date.now()).toISOString(),
bristolType,
color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.color) ? input.color : 'brown',
urgency: toSchemaInteger(input.urgency, { min: 0, max: 4, fallback: 0 }),
discomfort: toSchemaInteger(input.discomfort, { min: 0, max: 4, fallback: 0 }),
note: typeof input.note === 'string' ? input.note.trim().slice(0, 500) : '',
photoDataUrl: canonicalRasterPhoto(input.photoDataUrl),
urgency: Math.min(4, Math.max(0, Number(input.urgency) || 0)),
discomfort: Math.min(4, Math.max(0, Number(input.discomfort) || 0)),
note: String(input.note || '').trim().slice(0, 500),
photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '',
symptoms,
};
if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin };
return entry;
}
export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) {
@@ -186,176 +77,19 @@ export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 }
return 'Ready for your review. Choose the matching Bristol form yourself; Timmy does not interpret the picture.';
}
// Total portability budget: storage, exports, and imports are all bounded by
// the same 16 MiB of UTF-8 bytes, measured on the exact document that would be
// written or read. Enforcing the TOTAL (not just the inbound file) keeps every
// export re-importable and prevents doomed writes from ever being attempted.
export function estimateLedgerBytes(entries) {
return utf8ByteLength(JSON.stringify(Array.isArray(entries) ? entries : []));
}
export function exportLedger(entries, exportedAt = new Date().toISOString()) {
const text = JSON.stringify({
product: PRODUCT_NAME,
schemaVersion: SCHEMA_VERSION,
return JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt,
entries: Array.isArray(entries) ? entries.map(sanitizeEntry) : [],
entries: Array.isArray(entries) ? entries : [],
}, null, 2);
if (utf8ByteLength(text) > MAX_IMPORT_BYTES) {
throw new RangeError('This ledger has grown past the portable size limit. Remove some large photos, then export again.');
}
return text;
}
export function utf8ByteLength(text) {
return UTF8_ENCODER.encode(text).length;
}
// Collision-safe deterministic merge with ID repair:
// - Every distinct existing (user-owned) record survives. Duplicate ids inside
// stored data are repaired, never dropped or silently merged: the first-seen
// row keeps its id and later twins get a deterministic `id#2`, `id#3`, …
// suffix, skipping suffixes already owned by real records.
// - Incoming rows are added only when their id is unused; intra-file duplicate
// ids keep exactly the first occurrence; every collision is reported back for
// explicit user feedback. Re-importing a file can therefore never duplicate,
// overwrite, or shadow user records.
// - Hostile id values (Symbol, BigInt, objects, missing) cannot throw: such
// records are repaired onto fresh deterministic string ids like "entry-3".
function repairLedgerIds(entries) {
const owned = new Set();
const repaired = [];
for (const entry of entries) {
let id = typeof entry?.id === 'string' ? entry.id : '';
if (!id || owned.has(id)) {
const base = id || 'entry';
let n = 2;
while (owned.has(`${base}#${n}`)) n += 1;
id = `${base}#${n}`;
}
owned.add(id);
repaired.push(entry);
if (repaired[repaired.length - 1] !== entry || entry.id !== undefined) entry.id = id;
}
return repaired;
}
export function mergeLedgers(existing, incoming) {
const local = repairLedgerIds(
Array.isArray(existing)
? existing.filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry))
: [],
);
for (const entry of local) entry.id = String(entry.id);
const seen = new Set(local.map(entry => entry.id));
const merged = [...local];
const added = [];
const skippedIds = [];
const source = Array.isArray(incoming) ? incoming : [];
for (const rawEntry of source) {
const rawId = rawEntry && typeof rawEntry === 'object' && !Array.isArray(rawEntry)
? (typeof rawEntry.id === 'string' ? rawEntry.id : '')
: '';
const entry = sanitizeEntry(rawEntry || {});
if (!entry.id || seen.has(entry.id)) {
skippedIds.push(rawId || entry.id);
continue;
}
seen.add(entry.id);
merged.push(entry);
added.push(entry);
}
return { merged, added, skippedIds };
}
// Portability policy (explicit bound, symmetric by construction):
// - The app accepts photos up to 4 MiB of decoded binary (analysis.js
// MAX_IMAGE_BYTES); base64 encoding expands that to ~5.6 MiB and JSON adds a
// small envelope per entry, so any export this app can produce fits inside
// 16 MiB.
// - Imports are rejected past MAX_IMPORT_BYTES UTF-8 bytes before parsing user
// data. Every app-produced export therefore re-imports byte-symmetrically,
// while hostile or runaway files stay bounded.
export const MAX_IMPORT_BYTES = 16 * 1024 * 1024;
const PRODUCT_NAME = 'Timmy the Talking Turd';
const SCHEMA_VERSION = 1;
const KNOWN_SCHEMA_VERSIONS = new Set([0, SCHEMA_VERSION]);
// A bare top-level JSON array is accepted only when it matches the strict
// Timmy legacy ledger shape: at least one row, every row a plain object
// carrying a nonempty string id and an integer Bristol type (1–7). Arbitrary
// arrays of unrelated objects are rejected outright — import must never
// invent plausible medical defaults from JSON that was never a Timmy export.
function looksLikeLegacyTimmyArray(value) {
return Array.isArray(value)
&& value.length > 0
&& value.every(row => row !== null && typeof row === 'object' && !Array.isArray(row)
&& typeof row.id === 'string' && row.id.trim() !== ''
&& Number.isInteger(row.bristolType)
&& row.bristolType >= 1 && row.bristolType <= 7);
}
export function importLedger(text) {
if (typeof text !== 'string' || text.length === 0) throw new Error('This is not a supported Timmy export.');
if (utf8ByteLength(text) > MAX_IMPORT_BYTES) throw new RangeError('That file is too large to be a Timmy export.');
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('This is not a supported Timmy export.');
}
if (Array.isArray(parsed)) {
if (!looksLikeLegacyTimmyArray(parsed)) throw new Error('This is not a supported Timmy export.');
return parsed.map(sanitizeEntry);
}
const hasLedgerEnvelope = typeof parsed?.product === 'string'
&& Number.isInteger(parsed?.schemaVersion)
&& Array.isArray(parsed?.entries);
if (!hasLedgerEnvelope || parsed.product !== PRODUCT_NAME) {
throw new Error('This is not a supported Timmy export.');
}
if (!KNOWN_SCHEMA_VERSIONS.has(parsed.schemaVersion)) {
throw new RangeError(`This export uses schema version ${parsed.schemaVersion} from a newer Timmy app. Update Timmy first, then import again.`);
}
const entries = parsed.entries.map(sanitizeEntry);
if (estimateLedgerBytes(entries) > MAX_IMPORT_BYTES) {
throw new RangeError('That file would expand past the portable size limit once migrated.');
}
return entries;
}
// Storage migration contract: whatever is in localStorage is validated and
// migrated BEFORE the UI renders, so malformed history can never blank the
// journal or crash boot.
// - Unparseable or wrong-shaped payloads collapse to an empty ledger instead
// of throwing; the caller learns via `changed` that a repairing rewrite is
// warranted.
// - Every distinct record survives: hostile fields are sanitized per entry,
// invalid dates become valid ISO timestamps, duplicate ids are repaired
// deterministically. Rows carrying neither an id nor a Bristol type are
// junk, never fabricated into records.
// - Healthy storage round-trips byte-for-byte and reports `changed: false`,
// so the app never rewrites what does not need repair.
export function migrateStoredLedger(raw) {
const empty = { entries: [], changed: false };
if (typeof raw !== 'string' || raw.trim() === '') return empty;
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return { entries: [], changed: true };
}
if (!Array.isArray(parsed)) return { entries: [], changed: true };
const rows = parsed.filter(row => row !== null && typeof row === 'object' && !Array.isArray(row)
&& ((typeof row.id === 'string' && row.id.trim() !== '') || Number.isInteger(row.bristolType)));
const entries = repairLedgerIds(rows.map(row => sanitizeEntry(row || {})));
let changed;
try {
changed = rows.length !== parsed.length || JSON.stringify(entries) !== JSON.stringify(rows);
} catch {
changed = true; // hostile stored structures always warrant a repairing rewrite
}
return { entries, changed };
const parsed = JSON.parse(text);
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.');
return parsed.entries.map(sanitizeEntry);
}
export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]);
+322
View File
@@ -0,0 +1,322 @@
const SCHEMA_VERSION = 1;
const BOUNDARIES = new Set(['app', 'api', 'queue', 'model']);
const STATUSES = new Set(['pass', 'fail', 'degraded', 'unknown']);
// Closed privacy-safe vocabulary: a failure class can never carry free text,
// secrets, medical notes, control characters, or terminal escapes because only
// these dotted lowercase slugs may ever reach rendering.
const FAILURE_CLASSES = new Set(['worker.unavailable', 'vision.timeout', 'model.error']);
const FAILURE_CLASS_PATTERN = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*){1,2}$/;
const FORBIDDEN_TOP_KEYS = new Set([
'session', 'sessionid', 'sessionidtoken', 'sessiontoken', 'cookie', 'cookies',
'authorization', 'authheader', 'apikey', 'api_key', 'password', 'credential',
'credentials', 'environment', 'envdump', 'photos', 'photo', 'image', 'images',
'base64', 'note', 'notetext', 'notes', 'user', 'username', 'email', 'identity',
]);
const FORBIDDEN_CHECK_KEYS = new Set([
...FORBIDDEN_TOP_KEYS,
'imagehash', 'base64payload', 'payload', 'blob', 'rawoutput',
]);
const COUNTER_KEYS = new Set(['ok', 'fail', 'abstain', 'retry', 'timeout', 'rejected', 'fallback', 'depth']);
const COUNTER_CEILING = 1_000_000;
const LATENCY_CEILING_MS = 3_600_000;
const CHECK_KEYS = new Set(['id', 'boundary', 'status', 'failureClass', 'latencyMs', 'counters']);
// Strict JSON-type gates: evidence crosses a JSON boundary, so only real JSON
// types may pass. Strings that merely look like numbers, booleans, nulls,
// non-finite values, and fractions are coercion attempts and must fail closed
// instead of being silently converted by Number().
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isBoundedInteger(value, { min, max }) {
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
}
function boundedCounters(raw) {
if (raw === undefined) return {};
if (!isPlainObject(raw)) return null;
const counters = {};
for (const key of Object.keys(raw).sort()) {
if (!COUNTER_KEYS.has(key)) return null;
if (!isBoundedInteger(raw[key], { min: 0, max: COUNTER_CEILING })) return null;
counters[key] = raw[key];
}
return counters;
}
const GENERATED_AT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
// Canonical real UTC: the string must match the exact grammar AND survive a
// Date round-trip byte-identically, so impossible dates (Feb 30, hour 25,
// month 13) and non-canonical spellings (offsets, lowercase designators, a
// space separator) are rejected rather than normalized into something else.
function isCanonicalUtc(value) {
if (typeof value !== 'string' || !GENERATED_AT_PATTERN.test(value)) return false;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return false;
// toISOString() always carries milliseconds, so strip them before comparing
// to the canonical seconds-precision spelling the grammar requires.
return parsed.toISOString().replace(/\.\d{3}Z$/, 'Z') === value;
}
// A check is either fully valid or the whole evidence object is rejected.
// Malformed checks may never silently disappear beside healthy ones: censoring
// a failure would dress a gap up as health, so every field is type-gated and
// any violation fails closed for the entire file.
function buildCheck(raw) {
if (!isPlainObject(raw)) return { rejected: true };
const id = raw.id;
if (typeof id !== 'string' || !/^[a-z][a-z0-9.-]{0,63}$/.test(id)) return { rejected: true };
if (!Object.hasOwn(raw, 'boundary') || !BOUNDARIES.has(raw.boundary)) return { rejected: true };
const status = raw.status;
if (typeof status !== 'string' || !STATUSES.has(status)) return { rejected: true };
let failureClass = null;
if (raw.failureClass !== undefined && raw.failureClass !== null) {
failureClass = raw.failureClass;
if (
typeof failureClass !== 'string'
|| !FAILURE_CLASSES.has(failureClass)
|| !FAILURE_CLASS_PATTERN.test(failureClass)
) {
return { rejected: true };
}
if (status === 'pass' || status === 'unknown') {
// Consistency contract: only failing/degraded checks may classify their
// failures. A pass or unknown can therefore never contribute to
// worker.outage, and all-pass evidence can never page anyone.
// A fail/degraded may stay classless (the schema keeps failureClass
// nullable); carrying a class is optional, never mandatory.
return { rejected: true };
}
}
const counters = boundedCounters(raw.counters);
if (counters === null) return { rejected: true };
const latencyMs = raw.latencyMs;
if (latencyMs !== undefined) {
// The schema allows an integer or omission — explicit null is neither,
// and accepting it here would let a second pass rewrite the shape.
if (!isBoundedInteger(latencyMs, { min: 0, max: LATENCY_CEILING_MS })) {
return { rejected: true };
}
}
for (const key of Object.keys(raw)) {
if (!CHECK_KEYS.has(key)) return { rejected: true };
}
const check = {
id,
boundary: raw.boundary,
status,
failureClass,
counters,
};
if (latencyMs !== undefined && latencyMs !== null) check.latencyMs = latencyMs;
return { check, dropped: false };
}
export function sanitizeEvidence(input) {
if (!isPlainObject(input)) return { ok: false, evidence: null };
// Forbidden-shaped top-level fields reject the whole evidence object.
// Silently ignoring them would let a censored file pass as healthy; a
// hostile file must be refused loudly instead of laundered.
for (const key of Object.keys(input)) {
if (FORBIDDEN_TOP_KEYS.has(key.replace(/[^a-z0-9]/gi, '').toLowerCase())) {
return { ok: false, evidence: null };
}
}
// Identity fields keep their exact safe JSON types; anything else is a
// type-confusion attempt and fails closed.
const commit = input.commit;
if (typeof commit !== 'string' || !/^[0-9a-f]{12,40}$/.test(commit)) return { ok: false, evidence: null };
const releaseTag = input.releaseTag;
if (
typeof releaseTag !== 'string'
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(releaseTag)
) {
return { ok: false, evidence: null };
}
const generatedAtUtc = input.generatedAtUtc;
if (!isCanonicalUtc(generatedAtUtc)) return { ok: false, evidence: null };
// schemaVersion is the JSON number 1 exactly — "1", true, null, 1.0-style
// fractions, and every other lookalike are refused.
if (input.schemaVersion !== SCHEMA_VERSION) return { ok: false, evidence: null };
if (!Array.isArray(input.checks) || input.checks.length === 0 || input.checks.length > 100) {
return { ok: false, evidence: null };
}
const checks = [];
for (const raw of input.checks) {
const built = buildCheck(raw);
if (built.rejected) return { ok: false, evidence: null };
checks.push(built.check);
}
if (checks.length === 0) return { ok: false, evidence: null };
// Fail closed on partial telemetry: an evidence set that is missing any of
// the four boundaries cannot be distinguished from a censored one, so the
// dashboard refuses it instead of rendering silent gaps as healthy.
const presentBoundaries = new Set(checks.map(check => check.boundary));
for (const boundary of BOUNDARIES) {
if (!presentBoundaries.has(boundary)) return { ok: false, evidence: null };
}
return {
ok: true,
evidence: {
schemaVersion: SCHEMA_VERSION,
releaseTag,
commit,
generatedAtUtc,
checks,
},
};
}
function emptyBoundary() {
return { checks: 0, pass: 0, degraded: 0, fail: 0, unknown: 0, latencyMs: null, counters: {} };
}
export function buildDashboard(evidence) {
const validated = sanitizeEvidence(evidence);
if (!validated.ok) return { ok: false, boundaries: null, totals: null, failureClasses: null, manualFallback: null };
const clean = validated.evidence;
const boundaries = { app: emptyBoundary(), api: emptyBoundary(), queue: emptyBoundary(), model: emptyBoundary() };
const failureClasses = {};
const totals = { checks: 0, pass: 0, degraded: 0, fail: 0, unknown: 0 };
for (const check of clean.checks) {
const bucket = boundaries[check.boundary];
bucket.checks += 1;
bucket[check.status] += 1;
if (check.latencyMs != null && (bucket.latencyMs == null || check.latencyMs > bucket.latencyMs)) {
bucket.latencyMs = check.latencyMs;
}
for (const key of Object.keys(check.counters)) {
bucket.counters[key] = (bucket.counters[key] || 0) + check.counters[key];
}
if (check.failureClass) {
failureClasses[check.failureClass] = (failureClasses[check.failureClass] || 0) + 1;
}
totals.checks += 1;
totals[check.status] += 1;
}
// Deterministic manual-fallback contract, truthful under every status:
// - app-boundary fail -> { available: false, reason: 'app-down' }
// the journal is served by the app, so a dead app takes it down too;
// - app-boundary unknown -> { available: null, reason: 'app-state-unknown' }
// availability cannot be claimed from unknown telemetry;
// - any other degradation -> { available: true, reason: 'local-journal' };
// - everything explicitly passing -> { available: true, reason: 'none-required' }.
const appBoundary = boundaries.app;
const anyDegradation = totals.fail > 0 || totals.degraded > 0;
const anyUnknown = totals.unknown > 0;
let manualFallback;
if (appBoundary.fail > 0) {
manualFallback = { available: false, reason: 'app-down' };
} else if (anyUnknown) {
manualFallback = { available: null, reason: 'app-state-unknown' };
} else if (anyDegradation) {
manualFallback = { available: true, reason: 'local-journal' };
} else {
manualFallback = { available: true, reason: 'none-required' };
}
return {
ok: true,
identity: { releaseTag: clean.releaseTag, commit: clean.commit, generatedAtUtc: clean.generatedAtUtc },
boundaries,
totals,
failureClasses,
manualFallback,
};
}
export const DEFAULT_ALERT_RULES = [
{
id: 'worker.outage',
severity: 'page',
owner: 'release-operator',
threshold: 1,
runbook: 'docs/RELEASE-OBSERVABILITY.md#simulate-a-worker-outage',
count: dashboard =>
Math.max(
dashboard.failureClasses['worker.unavailable'] || 0,
dashboard.boundaries.model.fail > 0 && dashboard.boundaries.queue.fail > 0
? dashboard.boundaries.model.fail + dashboard.boundaries.queue.fail + dashboard.boundaries.api.fail
: 0,
),
},
{
id: 'telemetry.gap',
severity: 'warn',
owner: 'release-operator',
threshold: 1,
runbook: 'docs/RELEASE-OBSERVABILITY.md#alert-inventory',
count: dashboard => dashboard.totals.unknown,
},
{
id: 'queue.backlog',
severity: 'warn',
owner: 'release-operator',
threshold: 10,
runbook: 'docs/RELEASE-OBSERVABILITY.md#alert-inventory',
count: dashboard => dashboard.boundaries.queue.counters.depth || 0,
},
{
id: 'vision.degraded',
severity: 'warn',
owner: 'vision-owner',
threshold: 3,
runbook: 'docs/RELEASE-OBSERVABILITY.md#alert-inventory',
count: dashboard => dashboard.failureClasses['vision.timeout'] || 0,
},
{
id: 'app.unhealthy',
severity: 'page',
owner: 'release-operator',
threshold: 1,
runbook: 'docs/RELEASE-OBSERVABILITY.md#alert-inventory',
count: dashboard => dashboard.boundaries.app.fail,
},
];
export function evaluateAlerts(dashboard, rules = DEFAULT_ALERT_RULES) {
if (!dashboard || !dashboard.ok) return [];
const alerts = [];
for (const rule of rules) {
let count = 0;
try {
count = Number(rule.count(dashboard));
} catch {
count = 0;
}
if (!Number.isFinite(count) || count <= 0 || count < rule.threshold) continue;
// Correlation: while a worker outage is paging, queue depth is residual
// noise from the same incident, so the backlog warn would only duplicate
// the page. It stays active the moment the page clears.
if (rule.id === 'queue.backlog' && alerts.some(alert => alert.id === 'worker.outage')) continue;
alerts.push({
id: rule.id,
severity: rule.severity,
owner: rule.owner,
threshold: rule.threshold,
count,
runbook: rule.runbook,
message:
rule.id === 'worker.outage'
? `${rule.severity.toUpperCase()} worker.unavailable on ${dashboard.identity.releaseTag} (${dashboard.identity.commit.slice(0, 12)}): ${count} failing boundary checks — follow ${rule.runbook} and keep the local journal as fallback.`
: `${rule.severity.toUpperCase()} ${rule.id} on ${dashboard.identity.releaseTag}: observed ${count} (threshold ${rule.threshold}) — see ${rule.runbook}.`,
});
}
return alerts;
}
+1 -11
View File
@@ -57,17 +57,7 @@ test('rejects malformed model output instead of guessing defaults', () => {
test('merges only visual fields and preserves user-reported context', () => {
const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } };
const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 });
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true }, provenance: { origin: 'ai-suggestion' } });
});
test('records ai-suggestion provenance only when a suggestion is actually applied', () => {
const form = { bristolType: 2, color: 'green', urgency: 1, discomfort: 0, note: '', symptoms: {} };
const abstained = mergeVisualSuggestion(form, { status: 'needs_user_input', reason: 'too uncertain' });
assert.deepEqual(abstained, form);
assert.equal(abstained.provenance, undefined);
const suggested = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 5, color: 'yellow', confidence: 0.9 });
assert.deepEqual(suggested.provenance, { origin: 'ai-suggestion' });
assert.equal(suggested.urgency, form.urgency, 'nonvisual fields stay user-owned');
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } });
});
test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => {
-606
View File
@@ -8,12 +8,6 @@ import {
detectUrgentText,
exportLedger,
hasUrgentLedgerContext,
importLedger,
estimateLedgerBytes,
MAX_IMPORT_BYTES,
mergeLedgers,
migrateStoredLedger,
utf8ByteLength,
photoQualityMessage,
sanitizeEntry,
} from '../src/domain.js';
@@ -124,40 +118,6 @@ test('sanitizes a user entry to the MVP data contract', () => {
assert.equal(entry.unexpected, undefined);
});
test('keeps only a bounded provenance origin and strips smuggled secrets', () => {
const entry = sanitizeEntry({
id: 'prov-1',
bristolType: 4,
provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' },
});
assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' });
assert.doesNotMatch(JSON.stringify(entry), /secret|session/i);
});
test('omits provenance entirely when none was recorded', () => {
const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 });
assert.equal(entry.provenance, undefined);
});
test('rejects provenance origins outside the recorded vocabulary', () => {
for (const bogus of ['clinician', 'self-diagnosis', '']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } });
assert.equal(entry.provenance, undefined, bogus);
}
});
test('provenance membership is own-property safe: inherited Object names are not origins', () => {
for (const poisoned of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf', 'isPrototypeOf']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: poisoned } });
assert.equal(entry.provenance, undefined, poisoned);
assert.doesNotMatch(JSON.stringify(entry), new RegExp(poisoned), poisoned);
}
// Even a null-prototype provenance carrying a real origin stays acceptable.
const nullProto = sanitizeEntry({ id: 'y', bristolType: 4, provenance: Object.assign(Object.create(null), { origin: 'user' }) });
assert.deepEqual(nullProto.provenance, { origin: 'user' });
});
test('photo quality guidance is deterministic and does not claim visual diagnosis', () => {
assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i);
assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i);
@@ -172,569 +132,3 @@ test('export ledger is portable JSON with version and entries', () => {
assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z');
assert.equal(parsed.entries.length, 1);
});
test('import migrates the legacy bare-array ledger to the current versioned envelope', () => {
const legacy = JSON.stringify([
{ id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' },
{ id: 'legacy-2', bristolType: 7 },
]);
const entries = importLedger(legacy);
assert.equal(entries.length, 2);
assert.equal(entries[0].id, 'legacy-1');
assert.equal(entries[0].bristolType, 2);
assert.equal(entries[0].note, 'older export');
});
test('import accepts every prior schema version and migrates entries forward', () => {
for (const version of [0, 1]) {
const payload = version === 0
? [{ id: `v${version}`, bristolType: 3 }]
: { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] };
const entries = importLedger(JSON.stringify(payload));
assert.equal(entries.length, 1, `schemaVersion ${version}`);
assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`);
}
});
test('import fails safely on a newer schema version instead of guessing', () => {
for (const schemaVersion of [2, 99]) {
assert.throws(
() => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })),
error => error instanceof RangeError && /newer Timmy app/i.test(error.message),
`schemaVersion ${schemaVersion}`,
);
}
});
test('import fails safely on malformed or wrong-shaped payloads', () => {
for (const payload of [
'not json',
'{"schemaVersion":1,"entries":{}}',
'{"entries":[]}',
'{"product":"Other App","schemaVersion":1,"entries":[]}',
null,
42,
]) {
assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40));
}
});
test('import rejects oversized ledgers before parsing user data', () => {
const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] });
assert.ok(huge.length > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('utf8ByteLength measures UTF-8 bytes, not UTF-16 code units', () => {
assert.equal(utf8ByteLength(''), 0);
assert.equal(utf8ByteLength('abc'), 3);
// é is 1 UTF-16 unit but 2 UTF-8 bytes; 💩 is 2 UTF-16 units but 4 UTF-8 bytes.
assert.equal(utf8ByteLength('é'), 2);
assert.equal(utf8ByteLength('💩'), 4);
assert.equal(utf8ByteLength('aé💩b'), 1 + 2 + 4 + 1);
const emojiBlob = '💩'.repeat(1000);
assert.equal(emojiBlob.length, 2000, 'sanity: two code units each');
assert.equal(utf8ByteLength(emojiBlob), 4000);
});
test('import rejects oversized payloads by UTF-8 bytes regardless of composition', () => {
const asciiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES) + '"}]}';
assert.ok(utf8ByteLength(asciiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(asciiOver), RangeError);
// 4 bytes per glyph: byte size crosses the cap at half the code-unit count.
const emojiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + '💩'.repeat(Math.ceil(MAX_IMPORT_BYTES / 2)) + '"}]}';
assert.ok(emojiOver.length <= MAX_IMPORT_BYTES * 1.01, 'code-unit count must not be what trips this');
assert.ok(utf8ByteLength(emojiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(emojiOver), RangeError);
});
test('import accepts a dense multibyte payload just under the byte cap', () => {
// Many small multibyte entries packed deterministically so that both the raw
// file and the fully migrated ledger stay inside the total portability
// budget (migration expands each row to the strict current schema).
const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[';
const makeEntry = i => ({ id: `m${i}`, occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕', photoDataUrl: '', symptoms: {} });
const sampleMigrated = JSON.stringify(sanitizeEntry(makeEntry(0)));
const perRowRaw = utf8ByteLength(JSON.stringify(makeEntry(0))) + 1; // + comma
const perRowMigrated = utf8ByteLength(sampleMigrated) + 1;
// Fixed-width ids keep every row the same size, so packing is exact. The
// fill factor keeps BOTH documents inside the budget: the migrated ledger
// near the cap, the raw file above half of it.
const budget = Math.floor((MAX_IMPORT_BYTES * 0.9 - utf8ByteLength(head) - 2) / perRowMigrated);
const count = Math.max(1, budget);
const payload = `${head}${Array.from({ length: count }, (_, i) => JSON.stringify({ ...makeEntry(i), id: `m${String(i).padStart(8, '0')}` })).join(',')}]}`;
assert.ok(utf8ByteLength(payload) <= MAX_IMPORT_BYTES);
assert.ok(utf8ByteLength(payload) > MAX_IMPORT_BYTES * 0.5, 'payload must carry real multibyte mass');
const imported = importLedger(payload);
assert.equal(imported.length, count, 'every packed entry survives');
assert.ok(estimateLedgerBytes(imported) <= MAX_IMPORT_BYTES, 'migrated total must respect the portability budget');
assert.equal(imported[imported.length - 1].note, 'café ☕');
});
test('a maximal app-produced export with a 4 MiB photo round trips byte-symmetrically', () => {
// 4 MiB binary is the app-wide photo ceiling (analysis.js MAX_IMAGE_BYTES).
const photoBytes = 4 * 1024 * 1024;
let b64 = Buffer.from('a'.repeat(photoBytes)).toString('base64');
const entry = sanitizeEntry({
id: 'big-photo',
bristolType: 4,
photoDataUrl: `data:image/jpeg;base64,${b64}`,
note: 'boundary photo',
});
const exported = exportLedger([entry], '2026-08-22T00:00:00.000Z');
assert.ok(utf8ByteLength(exported) <= MAX_IMPORT_BYTES, 'largest producible export must stay inside the import cap');
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 1);
assert.equal(roundTripped[0].photoDataUrl, entry.photoDataUrl, 'photo survives the round trip without silent loss');
});
test('import rejects payloads past the explicit portability ceiling before parsing', () => {
const huge = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES + 1024) + '"}]}';
assert.ok(utf8ByteLength(huge) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('merge keeps every distinct record and never duplicates or overwrites user-owned entries', () => {
const local = [
{ id: 'a', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, note: 'local version of shared id' },
{ id: 'b', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'local b', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([
{ id: 'b', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, color: 'black', urgency: 4, note: 'hostile rewrite of existing id' },
{ id: 'c', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 6, note: 'new from file' },
]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 3, 'one row per unique id');
assert.equal(result.merged.filter(entry => entry.id === 'b').length, 1, 'no duplicate ids');
assert.equal(result.merged.find(entry => entry.id === 'b').note, 'local b', 'existing user-owned entry is never overwritten');
assert.deepEqual(result.added.map(entry => entry.id), ['c'], 'only genuinely new records are added');
assert.deepEqual(result.skippedIds, ['b'], 'collisions are reported explicitly');
// Order stays deterministic: local rows first in their stored order, then additions in incoming order.
assert.deepEqual(result.merged.map(entry => entry.id), ['a', 'b', 'c']);
});
test('re-importing the same file twice changes nothing (idempotent)', () => {
const base = [{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }];
const file = importLedger(JSON.stringify([{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }, { id: 'extra', occurredAt: '2026-08-22T08:30:00.000Z', bristolType: 3 }]));
const first = mergeLedgers(base, file);
assert.deepEqual(first.added.map(entry => entry.id), ['extra']);
const second = mergeLedgers(first.merged, file);
assert.equal(second.merged.length, first.merged.length, 'second import adds nothing');
assert.deepEqual(second.added, [], 'second import reports no additions');
assert.deepEqual(second.skippedIds.sort(), ['extra', 'seed'], 'both already-present ids are reported as skipped');
});
test('duplicate ids inside stored data are repaired deterministically, never dropped or silently merged', () => {
// Two genuinely different local records that share one id (legacy storage
// corruption or a double-save bug) must BOTH survive with distinct stable ids.
const local = [
{ id: 'twins', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first twin', photoDataUrl: '', symptoms: {} },
{ id: 'twins', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second twin', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([{ id: 'fresh', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4 }]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 3, 'every distinct record survives a duplicate-id collision');
const ids = result.merged.map(entry => entry.id);
assert.equal(new Set(ids).size, 3, 'no duplicate ids remain after repair');
assert.equal(result.merged[0].id, 'twins', 'first-seen record keeps its original id');
const secondTwin = result.merged.find(entry => entry.note === 'second twin');
assert.ok(secondTwin, 'second twin still present');
assert.equal(secondTwin.id, 'twins#2', 'derived id is deterministic, not random');
assert.equal(secondTwin.bristolType, 6, 'repaired record keeps its own data');
assert.deepEqual(result.added.map(entry => entry.id), ['fresh'], 'import still reports additions normally');
// Re-merging the repaired ledger is idempotent: nothing changes, nothing new.
const second = mergeLedgers(result.merged, incoming);
assert.equal(second.merged.length, 3, 'repaired ledger re-merges without growth');
assert.deepEqual(second.added, []);
});
test('repair ids never collide with real records: chained suffixes are skipped', () => {
const local = [
{ id: 'twins', bristolType: 2, note: 'a' },
{ id: 'twins#2', bristolType: 3, note: 'real record that owns the derived slot' },
{ id: 'twins', bristolType: 4, note: 'b' },
];
const result = mergeLedgers(local, []);
assert.equal(result.merged.length, 3, 'all three records survive');
const ids = result.merged.map(entry => entry.id);
assert.equal(new Set(ids).size, 3, 'derived id must not steal the real record\'s id');
assert.equal(result.merged.find(entry => entry.note === 'real record that owns the derived slot').id, 'twins#2');
assert.equal(result.merged.find(entry => entry.note === 'b').id, 'twins#3', 'duplicate scans past every owned suffix');
});
test('duplicate ids inside one imported file keep exactly the first occurrence', () => {
const incoming = importLedger(JSON.stringify([
{ id: 'dupe', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'first in file' },
{ id: 'dupe', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile later rewrite' },
]));
const result = mergeLedgers([], incoming);
assert.equal(result.merged.length, 1, 'one row per id, first occurrence wins deterministically');
assert.equal(result.merged[0].note, 'first in file');
assert.deepEqual(result.skippedIds, ['dupe'], 'later duplicates are reported, not silently dropped');
});
test('local duplicate ids still win over an incoming file that reuses their id', () => {
const local = [
{ id: 'twin', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'local twin one', photoDataUrl: '', symptoms: {} },
{ id: 'twin', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 3, note: 'local twin two', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([
{ id: 'twin', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile import' },
]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 2, 'both local records survive; hostile import adds nothing');
assert.ok(result.merged.every(entry => entry.note.startsWith('local twin')), 'import never shadows user-owned records');
assert.deepEqual(result.added, []);
assert.deepEqual(result.skippedIds, ['twin']);
});
test('merge tolerates hostile id types without throwing', () => {
const hostile = [
{ id: Symbol('sym'), bristolType: 2, note: 'symbol id' },
{ id: 123n, bristolType: 3, note: 'bigint id' },
{ bristolType: 4, note: 'no id at all' },
];
let result;
assert.doesNotThrow(() => { result = mergeLedgers(hostile, []); });
assert.equal(result.merged.length, 3, 'records with unusable ids are repaired, not dropped');
const ids = result.merged.map(entry => entry.id);
assert.equal(new Set(ids).size, 3);
assert.ok(ids.every(id => typeof id === 'string' && id !== ''), 'every repaired id is a nonempty string');
});
test('bare top-level arrays are accepted only as a strict Timmy legacy ledger', () => {
const legacy = [
{ id: 'l1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, note: '' },
{ id: 'l2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, color: 'green' },
];
assert.equal(importLedger(JSON.stringify(legacy)).length, 2, 'genuine legacy exports stay importable');
});
test('ambiguous arrays of unrelated objects are rejected, never defaulted into medical records', () => {
for (const payload of [
[{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }],
[{ name: 'Alice', role: 'admin' }, { name: 'Bob', role: 'user' }],
[{ sku: 'X1', quantity: 3 }],
[],
[42],
['2026-08-22T09:00:00.000Z'],
[null],
]) {
let entries;
assert.throws(
() => { entries = importLedger(JSON.stringify(payload)); },
/not a supported Timmy export/,
`must reject: ${JSON.stringify(payload).slice(0, 60)}`,
);
assert.equal(entries, undefined, 'no invented defaults may leak from rejected payloads');
}
});
test('legacy array rows need a nonempty string id and a Bristol type; one bad row rejects the batch', () => {
assert.throws(() => importLedger(JSON.stringify([{ id: 'x' }])), /not a supported Timmy export/, 'missing bristolType');
assert.throws(() => importLedger(JSON.stringify([{ bristolType: 4 }])), /not a supported Timmy export/, 'missing id');
assert.throws(() => importLedger(JSON.stringify([{ id: '', bristolType: 4 }])), /not a supported Timmy export/, 'empty id');
const mixed = JSON.stringify([{ id: 'ok-row', bristolType: 3 }, { foo: 1 }]);
assert.throws(() => importLedger(mixed), /not a supported Timmy export/, 'partial acceptance would invent data');
});
test('photos validate canonically: strict grammar, base64 round-trip, decoded size floor, and magic bytes', () => {
const jpegBytes = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]), Buffer.alloc(600, 0x33)]);
const pngBytes = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(600, 0x44)]);
const webpBytes = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x60, 0x02, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(500, 0x55)]);
const url = bytes => `base64:${bytes.toString('base64')}`;
const okJpeg = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`;
const okPng = `data:image/png;base64,${pngBytes.toString('base64')}`;
const okWebp = `data:image/webp;base64,${webpBytes.toString('base64')}`;
assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg);
assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng);
assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp);
// Mislabeled content is rejected even with perfect base64 grammar: the
// decoded bytes must carry the declared format's real magic signature.
const svgPayload = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
const svgAsJpeg = `data:image/jpeg;base64,${svgPayload.toString('base64')}`;
const htmlAsPng = `data:image/png;base64,${Buffer.from('<html><body>hi</body></html>').toString('base64')}`;
const pngBytesAsJpeg = `data:image/jpeg;base64,${pngBytes.toString('base64')}`;
for (const bad of [
svgAsJpeg,
htmlAsPng,
pngBytesAsJpeg,
'data:image/svg+xml;base64,PHN2Zy8+',
'data:image/gif;base64,R0lGODlh',
// Grammar violations: whitespace/newlines, excess padding, base64url alphabet.
`data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/(.{20})/, '$1\n')}`,
'data:image/jpeg;base64,aGVsbG8===',
`data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/A/g, '_')}`,
'data:image/jpeg;base64,%2Dencoded',
// Noncanonical tiny junk: grammatically valid but far below any real image.
`data:image/jpeg;base64,${Buffer.from('ok').toString('base64')}`,
`data:image/png;base64,${Buffer.alloc(31, 0x89).toString('base64')}`,
'http://example.com/photo.jpg',
42,
undefined,
]) {
const label = typeof bad === 'string' ? bad.slice(0, 44) : String((bad && bad.constructor && bad.constructor.name) || 'value');
assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${label}`);
}
// Decoded-binary ceiling mirrors the app-wide 4 MiB photo limit.
const overDecoded = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 3, 0x77)]).toString('base64')}`;
assert.ok(Buffer.from(overDecoded.slice(23), 'base64').length > 4 * 1024 * 1024);
assert.equal(sanitizeEntry({ id: 'huge', photoDataUrl: overDecoded }).photoDataUrl, '', 'decoded payload past the 4 MiB app ceiling is rejected');
});
test('sanitizeEntry absorbs Symbol, BigInt, and hostile date values without throwing', () => {
const hostile = {
id: Symbol('sym'),
occurredAt: { toString() { throw new Error('date toString boom'); } },
bristolType: 4n,
color: Symbol('green'),
urgency: BigInt(3),
discomfort: { valueOf() { throw new Error('valueOf boom'); } },
note: Object.create(Object.prototype, { toString: { value() { throw new Error('note boom'); } } }),
photoDataUrl: Symbol('photo'),
symptoms: null,
provenance: 'user',
extraSymbolKey: Symbol('ignored'),
};
hostile[Symbol('poison')] = 'never';
let entry;
assert.doesNotThrow(() => { entry = sanitizeEntry(hostile); });
assert.equal(typeof entry.id, 'string');
assert.match(entry.id, /^entry-/);
assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, 'hostile date becomes a safe ISO timestamp');
assert.equal(entry.bristolType, 4, 'BigInt Bristol type falls back to neutral default');
assert.equal(entry.color, 'brown', 'Symbol color falls back to the default');
assert.equal(entry.urgency, 0, 'BigInt urgency is not a schema integer');
assert.equal(entry.discomfort, 0);
assert.equal(entry.note, '', 'non-coercible notes become empty instead of crashing');
assert.equal(entry.photoDataUrl, '');
assert.deepEqual(entry.symptoms, { blood: false, blackOrDarkRed: false, severePain: false, vomiting: false, fever: false, cannotPassGas: false });
assert.doesNotThrow(() => JSON.stringify(entry), 'result must stay serializable');
const poisonedDate = new Date('2026-08-01T00:00:00.000Z');
Object.defineProperty(poisonedDate, 'getTime', { value() { throw new Error('getTime boom'); } });
let survived;
assert.doesNotThrow(() => { survived = sanitizeEntry({ id: 'pd', occurredAt: poisonedDate }); });
assert.equal(Number.isNaN(new Date(survived.occurredAt).getTime()), false);
let symbolDate;
assert.doesNotThrow(() => { symbolDate = sanitizeEntry({ id: 'sd', occurredAt: Symbol('nope') }); });
assert.match(symbolDate.occurredAt, /^\d{4}-\d{2}-\d{2}T/);
});
test('stored localStorage payloads are validated and migrated before any render', () => {
// Malformed real-world storage: an invalid date that would blank the UI,
// a duplicate id, and one garbage row that is not an entry at all.
const stored = JSON.stringify([
{ id: 'keep-1', occurredAt: 'not-a-real-date', bristolType: 3, color: 'brown', urgency: 2, discomfort: 1, note: 'broken date', photoDataUrl: '', symptoms: {} },
{ id: 'keep-2', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'healthy row', photoDataUrl: '', symptoms: {} },
{ id: 'keep-2', occurredAt: '2026-08-22T10:15:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'twin row', photoDataUrl: '', symptoms: {} },
{ completely: 'not an entry' },
'a bare string row',
17,
]);
let migrated;
assert.doesNotThrow(() => { migrated = migrateStoredLedger(stored); });
assert.equal(migrated.entries.length, 3, 'every distinct record survives; junk rows are dropped, never fabricated');
const ids = migrated.entries.map(entry => entry.id);
assert.equal(new Set(ids).size, 3, 'duplicate stored ids are repaired');
assert.equal(Number.isNaN(new Date(migrated.entries.find(entry => entry.note === 'broken date').occurredAt).getTime()), false,
'invalid dates become valid ISO timestamps so the UI can never blank out');
assert.equal(migrated.entries.find(entry => entry.note === 'twin row').id, 'keep-2#2');
assert.equal(typeof migrated.changed, 'boolean');
});
test('valid stored ledgers pass through migration unchanged', () => {
const healthy = [
sanitizeEntry({ id: 'h1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 }),
sanitizeEntry({ id: 'h2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, provenance: { origin: 'user' } }),
];
const result = migrateStoredLedger(JSON.stringify(healthy));
assert.deepEqual(result.entries, healthy, 'healthy storage is byte-for-byte stable through migration');
assert.equal(result.changed, false, 'no rewrite is flagged when nothing needed repair');
});
test('corrupt or wrong-shaped stored values migrate to an empty ledger instead of crashing boot', () => {
for (const corrupt of ['', ' ', '{not json', 'null', '"just a string"', '{"entries":[]}', '[]', 'undefined']) {
let result;
assert.doesNotThrow(() => { result = migrateStoredLedger(corrupt); }, `corrupt value: ${corrupt.slice(0, 24)}`);
assert.deepEqual(result.entries, [], `empty ledger for corrupt value: ${corrupt.slice(0, 24)}`);
if (result.changed !== undefined) assert.equal(typeof result.changed, 'boolean');
}
});
test('hostile stored values cannot crash the migration pass', () => {
const raw = JSON.stringify([{ id: 'x', occurredAt: '2026-08-01T09:00:00.000Z', bristolType: 4 }, { id: 'y', occurredAt: { evil: true }, bristolType: { deep: [1, 2] } }]);
let result;
assert.doesNotThrow(() => { result = migrateStoredLedger(raw); });
assert.equal(result.entries.length, 2);
assert.equal(Number.isNaN(new Date(result.entries[0].occurredAt).getTime()), false);
// Circular structures and hostile toJSON must also stay contained.
const circular = {};
circular.self = circular;
assert.doesNotThrow(() => migrateStoredLedger(circular));
const hostileToJson = [{ toJSON() { throw new Error('toJSON boom'); } }];
assert.doesNotThrow(() => migrateStoredLedger(hostileToJson));
});
test('ledger size is measured as the exact UTF-8 bytes storage will hold', () => {
const entries = [sanitizeEntry({ id: 'e1', bristolType: 4, note: 'café ☕' })];
assert.equal(estimateLedgerBytes(entries), utf8ByteLength(JSON.stringify(entries)));
assert.equal(estimateLedgerBytes([]), utf8ByteLength('[]'));
const withPhoto = [sanitizeEntry({ id: 'e2', bristolType: 2, photoDataUrl: `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(2048, 0x44)]).toString('base64')}` })];
assert.equal(estimateLedgerBytes(withPhoto), utf8ByteLength(JSON.stringify(withPhoto)));
});
test('exports past the total portability budget are refused instead of producing non-importable files', () => {
const bigPhoto = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 4, 0x66)]).toString('base64')}`;
const entries = [1, 2, 3, 4].map(n => sanitizeEntry({ id: `big-${n}`, bristolType: 4, photoDataUrl: bigPhoto }));
let text;
assert.throws(() => { text = exportLedger(entries, '2026-08-22T00:00:00.000Z'); }, RangeError,
'an export that could never re-import must not be produced');
assert.equal(text, undefined, 'no oversized document may leak from a refused export');
// A single maximal photo entry still exports fine.
assert.doesNotThrow(() => exportLedger([entries[0]], '2026-08-22T00:00:00.000Z'));
});
test('imports whose migrated total would exceed the portability budget are rejected before the caller can mutate', () => {
// Raw bytes stay just under the cap; the migrated ledger (every row expanded
// to the full current schema with explicit symptom fields) crosses it, so
// import must refuse up front instead of letting a doomed write happen.
const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[';
const makeRawEntry = i => `{"id":"x${String(i).padStart(7, '0')}","bristolType":4,"occurredAt":"x"}`;
// Solve the row count directly with uniform-width ids: raw bytes sit just
// under the cap while the migrated expansion crosses it.
const rowLen = utf8ByteLength(makeRawEntry(0));
const available = MAX_IMPORT_BYTES - 4096 - utf8ByteLength(head) - 3; // "],}" tail
const count = Math.max(1, Math.floor((available + 1) / (rowLen + 1))); // + comma per row
const raw = `${head}${Array.from({ length: count }, (_, i) => makeRawEntry(i)).join(',')}]}`
+ '';
assert.ok(utf8ByteLength(raw) <= MAX_IMPORT_BYTES, 'fixture raw bytes must stay inside the cap');
assert.ok(utf8ByteLength(raw) > MAX_IMPORT_BYTES * 0.9, 'fixture must sit close to the raw boundary');
let entries;
assert.throws(() => { entries = importLedger(raw); }, error => error instanceof RangeError && /expand past/.test(error.message),
'migrated total past the budget must refuse before returning entries');
assert.equal(entries, undefined, 'no entries may leak from a refused import');
});
test('merge sanitizes incoming entries so imports cannot smuggle hostile fields into storage', () => {
const incoming = [{ id: 'proto-entry', bristolType: 4, __proto__: { poisoned: true }, extra: 'strip me' }, { id: 'ctor-entry', bristolType: 4, sessionCookie: 'SID=x' }];
const result = mergeLedgers([], incoming);
assert.equal(result.merged.length, 2, 'both records still import as data');
for (const entry of result.merged) {
assert.equal(Object.getPrototypeOf(entry), Object.prototype, `plain-object entry ${entry.id}`);
assert.equal(entry.poisoned, undefined, 'prototype payload must not leak');
assert.equal(entry.extra, undefined, 'unknown fields stay out of storage');
assert.equal(entry.sessionCookie, undefined, 'smuggled secrets stay out of storage');
}
const serialized = JSON.stringify(result.merged);
assert.doesNotMatch(serialized, /poisoned|extra|sessionCookie|SID=/);
});
test('current-schema numeric fields are strict integers within clinical bounds', () => {
for (const bogus of [4.5, '3', 0, 8, NaN, null, true, [3]]) {
const entry = sanitizeEntry({ id: 'n', bristolType: bogus });
assert.equal(entry.bristolType, 4, `bristolType ${JSON.stringify(String(bogus))} falls back to the neutral default`);
}
assert.equal(sanitizeEntry({ id: 'ok1', bristolType: 1 }).bristolType, 1);
assert.equal(sanitizeEntry({ id: 'ok2', bristolType: 7 }).bristolType, 7);
const mixed = sanitizeEntry({ id: 'm', urgency: 2.5, discomfort: -1 });
assert.equal(mixed.urgency, 0);
assert.equal(mixed.discomfort, 0);
const strings = sanitizeEntry({ id: 's', urgency: '3', discomfort: 11 });
assert.equal(strings.urgency, 0, 'numeric strings are not schema integers');
assert.equal(strings.discomfort, 0);
const valid = sanitizeEntry({ id: 'v', urgency: 3, discomfort: 4 });
assert.equal(valid.urgency, 3);
assert.equal(valid.discomfort, 4);
});
test('photo fields accept only approved raster JPEG/PNG/WebP base64 data URLs', () => {
// Canonical, magic-valid samples of every supported format (large enough to
// satisfy the canonical photo contract's decoded-size floor).
const okJpeg = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(120, 0x11)]).toString('base64')}`;
const okPng = `data:image/png;base64,${Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(120, 0x22)]).toString('base64')}`;
const okWebp = `data:image/webp;base64,${Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x70, 0x00, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(110, 0x33)]).toString('base64')}`;
assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg);
assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng);
assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp);
for (const bad of [
'data:image/svg+xml;base64,PHN2Zy8+',
'data:image/svg+xml,<svg onload="alert(1)">',
'data:image/gif;base64,R0lGODlh',
'data:image/jpeg;base64,!!!not-base64!!!',
'data:image/jpeg,percent%2Dencoded',
'data:text/html;base64,PGh0bWw+',
'http://example.com/photo.jpg',
42,
// Grammar-valid but non-raster or undersized payloads are dropped too:
// the canonical contract rejects tiny junk that merely parses as base64.
'data:image/jpeg;base64,b2s=',
`data:image/jpeg;base64,${Buffer.from('plain text, not an image').toString('base64')}`,
]) {
assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${String(bad).slice(0, 40)}`);
}
});
test('invalid or missing dates never throw and never persist Invalid Date values', () => {
for (const bad of ['not-a-date', '2026-13-45T99:99:99Z', {}, ['2026-01-01'], true]) {
let entry;
assert.doesNotThrow(() => { entry = sanitizeEntry({ id: 'd', occurredAt: bad }); }, String(bad));
assert.match(entry.occurredAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, `safe ISO for ${String(bad)}`);
}
const blank = sanitizeEntry({ id: 'd2', occurredAt: '' });
assert.equal(Number.isNaN(new Date(blank.occurredAt).getTime()), false);
const kept = sanitizeEntry({ id: 'd3', occurredAt: '2026-08-01T10:00:00.000Z' });
assert.equal(kept.occurredAt, '2026-08-01T10:00:00.000Z', 'valid dates pass through unchanged');
});
test('imports containing invalid dates migrate forward instead of crashing the whole ledger', () => {
const payload = JSON.stringify([
{ id: 'bad-date', occurredAt: 'garbage-date-value', bristolType: 3 },
{ id: 'good-date', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 },
]);
const imported = importLedger(payload);
assert.equal(imported.length, 2, 'one bad field cannot destroy the batch');
assert.equal(Number.isNaN(new Date(imported[0].occurredAt).getTime()), false, 'bad date becomes a safe ISO timestamp');
assert.equal(imported[1].occurredAt, '2026-08-20T09:00:00.000Z');
});
test('round trip preserves confirmed values and provenance without leaking secrets', () => {
const saved = [
sanitizeEntry({
id: 'r1',
occurredAt: '2026-08-19T08:30:00.000Z',
bristolType: 2,
color: 'green',
urgency: 3,
discomfort: 2,
note: 'rough morning',
provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' },
}),
{
id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4,
note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack',
},
];
const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z');
assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/);
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 2);
assert.deepEqual(
{ id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note },
{ id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' },
);
assert.equal(roundTripped[0].symptoms.blood, false);
assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' });
assert.equal(roundTripped[1].bristolType, 4, 'out-of-range Bristol type falls back to the neutral default under strict schema');
assert.equal(roundTripped[1].color, 'brown');
assert.equal(roundTripped[1].urgency, 0, 'out-of-range urgency falls back to the neutral default');
assert.equal(roundTripped[1].discomfort, 0);
assert.deepEqual(roundTripped[1].provenance, undefined);
});
test('re-exporting an imported ledger converges to the same portable document', () => {
const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }];
const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z'));
const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z'));
assert.deepEqual(second, first);
});
-316
View File
@@ -1,316 +0,0 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const ROOT_STORE = 'timmy:/:ledger-v1';
const LEGACY_STORE = 'timmy-ledger-v1';
await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
page.on('dialog', dialog => dialog.accept());
await page.goto(process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// Seed one confirmed, user-owned entry with provenance.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{
id: 'seed-1', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green',
urgency: 3, discomfort: 2, note: 'seeded confirmed entry', photoDataUrl: '',
symptoms: { blood: false }, provenance: { origin: 'ai-suggestion' },
},
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 1, 'seeded entry renders');
// Export writes the versioned envelope with provenance and no secrets.
await page.locator('[data-view="calendar"]').last().click();
await page.locator('[data-view="privacy"]').click();
const downloadPromise = page.waitForEvent('download');
await page.locator('#export').click();
const download = await downloadPromise;
const exportedText = await download.path().then(readFile).then(buffer => buffer.toString('utf8'));
const exported = JSON.parse(exportedText);
assert.equal(exported.schemaVersion, 1);
assert.equal(exported.entries[0].provenance.origin, 'ai-suggestion');
assert.doesNotMatch(exportedText, /apiToken|sessionId|sk-/i);
// Importing a prior-version export merges instead of replacing user data.
const legacyPath = join(workDir, 'legacy-ledger.json');
await writeFile(legacyPath, JSON.stringify([
{ id: 'legacy-9', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'legacy import' },
]));
await page.setInputFiles('#import', legacyPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const mergedIds = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(mergedIds.sort(), ['legacy-9', 'seed-1'], 'import must merge, not replace');
// A future schema version fails safely and leaves the ledger untouched.
const futurePath = join(workDir, 'future-ledger.json');
await writeFile(futurePath, JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 2, entries: [{ id: 'from-the-future' }] }));
await page.setInputFiles('#import', futurePath);
await page.getByText(/newer Timmy app/).waitFor({ timeout: 5000 });
const afterFuture = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterFuture.sort(), ['legacy-9', 'seed-1'], 'failed import must not mutate the ledger');
// Malformed JSON fails safely and leaves the ledger untouched.
const malformedPath = join(workDir, 'malformed-ledger.json');
await writeFile(malformedPath, '{"schemaVersion":1,"entries":');
await page.setInputFiles('#import', malformedPath);
await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 });
const afterMalformed = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterMalformed.sort(), ['legacy-9', 'seed-1'], 'malformed import must not mutate the ledger');
await page.screenshot({ path: 'artifacts/portability-import-mobile.png', fullPage: false });
// Byte-limit enforcement in the browser: File.size is checked BEFORE the
// file is read, and UTF-8 bytes (not JS characters) are the measured unit.
const MAX_IMPORT_BYTES = await page.evaluate(async () => (await import('/src/domain.js')).MAX_IMPORT_BYTES);
const oversizePath = join(workDir, 'oversize-ledger.json');
await writeFile(oversizePath, Buffer.concat([
Buffer.from('{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"'),
Buffer.alloc(MAX_IMPORT_BYTES + 1, 0x6e),
Buffer.from('"}]}'),
]));
let textReads = 0;
await page.evaluate(() => {
const original = File.prototype.text;
File.prototype.text = function (...args) {
window.__fileTextReads = (window.__fileTextReads || 0) + 1;
return original.apply(this, args);
};
});
await page.setInputFiles('#import', oversizePath);
await page.getByText(/too large/i).waitFor({ timeout: 5000 });
textReads = await page.evaluate(() => window.__fileTextReads || 0);
assert.equal(textReads, 0, 'oversized files must be rejected by File.size before File.text()');
const afterOversize = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterOversize.sort(), ['legacy-9', 'seed-1'], 'oversized import must not mutate the ledger');
// Multibyte boundary: a payload whose UTF-8 byte size exceeds the cap while
// its JS character count does not must still be rejected (byte-exact limit).
const emojiHead = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[{"id":"e","occurredAt":"2026-08-20T09:00:00.000Z","bristolType":4,"color":"brown","urgency":0,"discomfort":0,"note":"';
const emojiTail = '"}]}';
const emojiNoteUnits = Math.ceil(MAX_IMPORT_BYTES / 3); // ~1.33x cap in UTF-8 bytes, ~0.67x cap in JS units
const multibyteOverBytesPath = join(workDir, 'multibyte-over-bytes.json');
await writeFile(multibyteOverBytesPath, Buffer.from(emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail, 'utf8'));
const multibyteStats = await page.evaluate(payload => {
return { bytes: new TextEncoder().encode(payload).length, units: payload.length };
}, emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail);
assert.ok(multibyteStats.bytes > MAX_IMPORT_BYTES, 'fixture must exceed the cap in UTF-8 bytes');
assert.ok(multibyteStats.units <= MAX_IMPORT_BYTES, 'fixture must stay under the cap in JS characters');
await page.setInputFiles('#import', multibyteOverBytesPath);
await page.getByText(/too large/i).waitFor({ timeout: 5000 });
const afterMultibyte = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterMultibyte.sort(), ['legacy-9', 'seed-1'], 'multibyte over-byte import must not mutate the ledger');
// A dense multibyte payload just UNDER the byte cap still imports cleanly.
const underBytesPath = join(workDir, 'multibyte-under-bytes.json');
const underPayload = JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt: '2026-08-22T00:00:00.000Z',
entries: [{ id: 'under-1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕'.repeat(2000), photoDataUrl: '', symptoms: {} }],
});
const underBytes = await page.evaluate(payload => new TextEncoder().encode(payload).length, underPayload);
assert.ok(underBytes <= MAX_IMPORT_BYTES && underBytes > 10000, 'under-cap fixture must carry real multibyte mass');
await writeFile(underBytesPath, Buffer.from(underPayload, 'utf8'));
await page.setInputFiles('#import', underBytesPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const afterUnderBytes = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.ok(afterUnderBytes.includes('under-1'), 'valid multibyte import lands in the ledger');
// Collision-safe merge: re-importing a file whose ids already exist must
// not duplicate or overwrite anything and must say so explicitly.
const beforeReimport = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE);
await page.setInputFiles('#import', underBytesPath);
await page.getByText(/already in your ledger/i).waitFor({ timeout: 5000 });
const afterReimport = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterReimport.sort(), JSON.parse(beforeReimport).map(entry => entry.id).sort(), 're-import is idempotent: no duplicates, no overwrites');
// RED 1 — transactional import: a quota failure during save must roll back
// BOTH the in-memory ledger and localStorage together.
const beforeQuota = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE);
const domCountBeforeQuota = await page.locator('.entry').count();
await page.evaluate(() => {
// Simulate storage exhaustion at the exact moment of persistence,
// keeping the original setter around for a faithful restore.
const real = Object.getOwnPropertyDescriptor(Storage.prototype, 'setItem');
window.__realSetItem = real;
Object.defineProperty(Storage.prototype, 'setItem', {
...real,
value: function setItem() { throw new DOMException('quota exceeded', 'QuotaExceededError'); },
});
});
const quotaImportPath = join(workDir, 'quota-import.json');
await writeFile(quotaImportPath, JSON.stringify([
{ id: 'quota-new-1', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'arrives right before quota failure', photoDataUrl: '', symptoms: {} },
]));
await page.setInputFiles('#import', quotaImportPath);
await page.getByText(/storage is full/i).waitFor({ timeout: 5000 });
const afterQuotaStorage = await page.evaluate(([storeKey]) => localStorage.getItem(storeKey), [ROOT_STORE]);
assert.equal(afterQuotaStorage, beforeQuota, 'localStorage must be untouched after a failed save');
const inMemoryIds = await page.evaluate(() => Array.from(document.querySelectorAll('.entry strong')).map(node => node.textContent));
assert.equal(await page.locator('.entry').count(), domCountBeforeQuota, 'rendered journal shows no partially imported rows');
assert.ok(!JSON.stringify(inMemoryIds).includes('quota-new-1'), 'in-memory ledger rolled back with storage');
await page.evaluate(() => {
Object.defineProperty(Storage.prototype, 'setItem', window.__realSetItem);
delete window.__realSetItem;
});
// RED 2 — pre-render migration: malformed stored history (invalid dates,
// duplicate ids) is repaired before render instead of blanking the UI.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{ id: 'broken-date', occurredAt: 'not-a-real-date', bristolType: 3, note: 'date was corrupted' },
{ id: 'twin-row', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'twin one' },
{ id: 'twin-row', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, note: 'twin two' },
]));
});
await page.reload({ waitUntil: 'networkidle' });
assert.ok((await page.getByText(/Timmy noticed|Journal/).count()) > 0 || (await page.locator('#app').innerText()).length > 0, 'app renders over malformed storage');
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 3, 'invalid date did not blank the UI; every distinct record renders');
const migratedStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE);
const brokenRow = migratedStore.find(entry => entry.note === 'date was corrupted');
assert.ok(brokenRow && !Number.isNaN(new Date(brokenRow.occurredAt).getTime()), 'stored invalid dates become valid ISO timestamps');
const twinRows = migratedStore.filter(entry => entry.id.startsWith('twin-row'));
assert.deepEqual(twinRows.map(entry => entry.id).sort(), ['twin-row', 'twin-row#2'], 'duplicate ids repaired deterministically in storage');
// RED 3 — ambiguous top-level arrays are rejected wholesale in the browser.
const unrelatedPath = join(workDir, 'unrelated-array.json');
await writeFile(unrelatedPath, JSON.stringify([{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }]));
const beforeUnrelated = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE);
await page.locator('[data-view="privacy"]').click();
await page.setInputFiles('#import', unrelatedPath);
await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 });
assert.equal(
await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE),
beforeUnrelated,
'rejected unrelated arrays never mutate the ledger',
);
// RED 4 — mislabeled SVG photos are dropped by canonical raster validation
// while genuinely canonical photos survive import untouched.
const svgB64 = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').toString('base64');
const fakePhotoEntry = {
id: 'fake-photo', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown',
urgency: 0, discomfort: 0, note: 'smuggled svg', symptoms: {},
photoDataUrl: `data:image/jpeg;base64,${svgB64}`,
};
const realJpeg = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(600, 0x33)]).toString('base64');
const realPhotoEntry = {
id: 'real-photo', occurredAt: '2026-08-20T10:00:00.000Z', bristolType: 2, color: 'green',
urgency: 1, discomfort: 0, note: 'genuine raster', symptoms: {},
photoDataUrl: `data:image/jpeg;base64,${realJpeg}`,
};
const photoImportPath = join(workDir, 'photo-contract.json');
await writeFile(photoImportPath, JSON.stringify([fakePhotoEntry, realPhotoEntry]));
await page.setInputFiles('#import', photoImportPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const afterPhotos = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE);
const fakeStored = afterPhotos.find(entry => entry.id === 'fake-photo');
assert.ok(fakeStored, 'the record itself still imports');
assert.equal(fakeStored.photoDataUrl, '', 'mislabeled SVG payload is stripped from the entry');
const realStored = afterPhotos.find(entry => entry.id === 'real-photo');
assert.equal(realStored.photoDataUrl, `data:image/jpeg;base64,${realJpeg}`, 'canonical raster photos survive byte-for-byte');
// RED 5 — duplicate-ID repair preserves both distinct local records in the
// full app flow, and the repair lands in storage.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{ id: 'dup-pair', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first distinct record', photoDataUrl: '', symptoms: {} },
{ id: 'dup-pair', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second distinct record', photoDataUrl: '', symptoms: {} },
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 2, 'both duplicate-id records render as distinct rows');
const dupStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE);
const dupIds = dupStore.map(entry => entry.id).sort();
assert.deepEqual(dupIds, ['dup-pair', 'dup-pair#2'], 'storage holds two distinct deterministic ids');
// Delete Everything removes every namespaced copy of the local ledger.
await page.locator('[data-view="privacy"]').click();
await page.locator('#delete-all').click();
await page.getByText('Local ledger deleted').waitFor({ timeout: 5000 });
const storesAfterDelete = await page.evaluate(([rootStore, legacyStore]) => ({
root: localStorage.getItem(rootStore),
legacy: localStorage.getItem(legacyStore),
}), [ROOT_STORE, LEGACY_STORE]);
assert.equal(storesAfterDelete.root, null, 'namespaced store cleared');
assert.equal(storesAfterDelete.legacy, null, 'legacy store cleared');
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 0, 'journal is empty after delete-all');
// Base-path deployments keep their ledger in an isolated namespace.
const staging = spawn(process.execPath, ['server.mjs'], {
env: { ...process.env, PORT: '4179', HOST: '127.0.0.1', TIMMY_BASE_PATH: '/timmy-staging' },
stdio: 'ignore',
});
try {
let up = false;
for (let attempt = 0; attempt < 40 && !up; attempt += 1) {
up = await fetch('http://127.0.0.1:4179/timmy-staging/api/healthz').then(response => response.ok).catch(() => false);
if (!up) await sleep(250);
}
assert.ok(up, 'staging server must start');
const stagingContext = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' });
const stagingPage = await stagingContext.newPage();
stagingPage.on('dialog', dialog => dialog.accept());
await stagingPage.goto('http://127.0.0.1:4179/timmy-staging', { waitUntil: 'networkidle' });
await stagingPage.evaluate(() => {
localStorage.clear();
localStorage.setItem('timmy:/timmy-staging:ledger-v1', JSON.stringify([
{ id: 'staging-1', occurredAt: '2026-08-21T10:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'staging only', photoDataUrl: '', symptoms: {} },
]));
// Legacy root-deployment ledger living in the same origin's storage.
localStorage.setItem('timmy-ledger-v1', JSON.stringify([
{ id: 'root-legacy-1', occurredAt: '2026-08-19T09:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'root legacy ledger', photoDataUrl: '', symptoms: {} },
]));
});
await stagingPage.reload({ waitUntil: 'networkidle' });
await stagingPage.locator('[data-view="calendar"]').last().click();
assert.equal(await stagingPage.locator('.entry').count(), 1, 'staging entry renders under its base path');
const isolation = await stagingPage.evaluate(() => ({
staging: localStorage.getItem('timmy:/timmy-staging:ledger-v1'),
root: localStorage.getItem('timmy:/:ledger-v1'),
legacy: localStorage.getItem('timmy-ledger-v1'),
}));
assert.ok(isolation.staging, 'staging store keeps its data');
assert.equal(isolation.root, null, 'root-namespaced store untouched by staging data');
assert.ok(isolation.legacy && JSON.parse(isolation.legacy).some(entry => entry.id === 'root-legacy-1'), 'legacy store untouched by staging session');
await stagingPage.locator('[data-view="privacy"]').click();
await stagingPage.locator('#delete-all').click();
await stagingPage.getByText('Local ledger deleted').waitFor({ timeout: 5000 });
const stagingAfterDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy:/timmy-staging:ledger-v1'));
assert.equal(stagingAfterDelete, null, 'delete-all clears the base-path store');
const legacyAfterStagingDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy-ledger-v1'));
assert.ok(
legacyAfterStagingDelete && JSON.parse(legacyAfterStagingDelete).some(entry => entry.id === 'root-legacy-1'),
'base-path delete-all must not erase another namespace’s global legacy ledger',
);
await stagingContext.close();
} finally {
staging.kill();
}
assert.deepEqual(errors, [], 'no console or page errors');
await context.close();
console.log('PASS ledger portability: export round trip, merge import, safe failures, isolation, delete-all');
} finally {
await browser.close();
await rm(workDir, { recursive: true, force: true });
}
});
+1 -2
View File
@@ -23,8 +23,7 @@ await page.route('**/api/analyze', route => route.fulfill({
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
}),
}));
const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173';
await page.goto(appUrl, { waitUntil: 'networkidle' });
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
+338
View File
@@ -0,0 +1,338 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import http from 'node:http';
const root = fileURLToPath(new URL('..', import.meta.url));
let nextPort = 43600;
const healthyEvidence = {
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 },
{ id: 'api.analyze', boundary: 'api', status: 'pass', counters: { ok: 40, fail: 0 } },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 2, ok: 30, fail: 0 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 900, counters: { ok: 25, fail: 0, abstain: 3 } },
],
};
function runCli(args) {
return new Promise(resolve => {
const child = spawn(process.execPath, ['scripts/release_dashboard.mjs', ...args], {
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
const timer = setTimeout(() => child.kill('SIGKILL'), 15_000);
child.on('close', code => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
}
test('cli renders boundary dashboard with zero alerts from sanitized evidence file', async () => {
const dir = await mkdtemp(join(tmpdir(), 'timmy-obs-'));
try {
const evidencePath = join(dir, 'evidence.json');
await writeFile(evidencePath, JSON.stringify(healthyEvidence));
const result = await runCli(['--evidence', evidencePath]);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /RELEASE OBSERVABILITY DASHBOARD/);
assert.match(result.stdout, /release daily-2026-08-22\.1 · commit ca31e6d38bec/);
assert.match(result.stdout, /app\s+1\/1 pass/);
assert.match(result.stdout, /queue\s+1\/1 pass/);
assert.match(result.stdout, /model\s+1\/1 pass/);
assert.match(result.stdout, /manual fallback: available/);
assert.match(result.stdout, /alerts \(0\)/);
assert.doesNotMatch(result.stdout, /token|cookie|session|password/i);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('cli exits nonzero on unsanitizable evidence and never echoes its contents', async () => {
const dir = await mkdtemp(join(tmpdir(), 'timmy-obs-'));
try {
const hostile = {
...healthyEvidence,
sessionToken: 'sess_live_supersecret99',
checks: [{ id: 'x', boundary: 'nope-boundary', status: 'weird' }],
};
const evidencePath = join(dir, 'hostile.json');
await writeFile(evidencePath, JSON.stringify(hostile));
const result = await runCli(['--evidence', evidencePath]);
assert.equal(result.code, 2);
assert.doesNotMatch(result.stderr + result.stdout, /supersecret/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('cli output stays control-safe when rejected evidence paths carry ANSI escapes', async () => {
const evilPath = join(tmpdir(), `timmy-\x1b[31mred\rbell\x07.json`);
const result = await runCli(['--evidence', evilPath]);
assert.equal(result.code, 2);
const emitted = result.stdout + result.stderr;
assert.doesNotMatch(emitted, /\x1b/, 'ANSI escape reached the terminal');
assert.doesNotMatch(emitted, /\x07/, 'bell control character reached the terminal');
assert.doesNotMatch(emitted, /\r/, 'carriage return reached the terminal');
});
test('cli output stays control-safe when hostile paths carry LF-forged PAGE lines', async () => {
// The path embeds a real newline followed by a forged terminal line, as if
// an attacker-supplied filename tried to end the error line early and page
// the operator with fake drill output.
const forgedPath = join(tmpdir(), `timmy-legit.json\nPAGE worker.outage — ALL SYSTEMS DOWN\r\nEXIT 1 DRILL FAIL`);
const result = await runCli(['--evidence', forgedPath]);
assert.equal(result.code, 2);
const emitted = result.stdout + result.stderr;
const lines = emitted.split('\n').filter(line => line.length > 0);
for (const line of lines) {
assert.match(line, /^release_dashboard:/, `forged line escaped into the terminal: ${JSON.stringify(line)}`);
}
assert.doesNotMatch(emitted, /ALL SYSTEMS DOWN/, 'LF-forged PAGE text reached the terminal');
assert.doesNotMatch(emitted, /DRILL FAIL/, 'LF-forged drill verdict reached the terminal');
});
test('successful dashboard rendering emits no control characters besides newlines', async () => {
const dir = await mkdtemp(join(tmpdir(), 'timmy-obs-'));
try {
const evidencePath = join(dir, 'evidence.json');
await writeFile(evidencePath, JSON.stringify(healthyEvidence));
const result = await runCli(['--evidence', evidencePath]);
assert.equal(result.code, 0, result.stderr);
assert.doesNotMatch(result.stdout, /[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/, 'control character reached the terminal');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('cli never claims proven availability or no degradation from unknown telemetry', async () => {
const dir = await mkdtemp(join(tmpdir(), 'timmy-obs-'));
try {
const unknownEvidence = {
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'unknown', latencyMs: 12 },
{ id: 'api.analyze', boundary: 'api', status: 'pass', counters: { ok: 40, fail: 0 } },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 2, ok: 30, fail: 0 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 900, counters: { ok: 25, fail: 0 } },
],
};
const evidencePath = join(dir, 'unknown.json');
await writeFile(evidencePath, JSON.stringify(unknownEvidence));
const result = await runCli(['--evidence', evidencePath]);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /manual fallback: unknown — app state unknown; verify the local journal by hand before relying on it/);
assert.doesNotMatch(result.stdout, /no degradation detected/, 'unknown app state must never read as proven health');
assert.doesNotMatch(result.stdout, /manual fallback: available/, 'unknown app state must never read as proven availability');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
async function startDrillServer({ startDown = false, flipHasNoEffect = false, preDrillChecks = null } = {}) {
const port = nextPort++;
const origin = `http://127.0.0.1:${port}`;
let workerUp = !startDown;
let outageSwitchCount = 0;
const app = http.createServer((req, res) => {
const url = new URL(req.url, origin);
if (url.pathname === '/drill/outage' && req.method === 'POST') {
outageSwitchCount += 1;
if (!flipHasNoEffect) workerUp = false;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
if (url.pathname === '/api/healthz') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, release: 'daily-2026-08-22.1', commit: 'ca31e6d38bec649407f63880504554c59f2878ae', visionEnabled: false, agentEnabled: false }));
return;
}
if (url.pathname === '/api/drill/checks') {
const appCheck = { id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 };
const checks = preDrillChecks ?? (workerUp
? [
appCheck,
{ id: 'api.analyze', boundary: 'api', status: 'pass', counters: { ok: 5, fail: 0 } },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 1, ok: 5, fail: 0 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 800, counters: { ok: 4, fail: 0 } },
]
: [
appCheck,
{ id: 'api.analyze', boundary: 'api', status: 'fail', failureClass: 'worker.unavailable' },
{ id: 'queue.depth', boundary: 'queue', status: 'fail', failureClass: 'worker.unavailable', counters: { depth: 17 } },
{ id: 'model.inference', boundary: 'model', status: 'fail', failureClass: 'worker.unavailable', counters: { timeout: 4 } },
]);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ checks }));
return;
}
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('Not found');
});
await new Promise(resolve => app.listen(port, '127.0.0.1', resolve));
return {
origin,
close: () => new Promise(resolve => app.close(() => resolve())),
get outageSwitchCount() { return outageSwitchCount; },
};
}
test('outage drill reads the genuine fixture baseline, flips once, and reports exactly one page alert plus manual fallback', async t => {
const server = await startDrillServer();
t.after(() => server.close());
const result = await runCli(['--drill-origin', server.origin]);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /WORKER OUTAGE DRILL/);
assert.match(result.stdout, /pre-drill baseline: every required boundary check explicitly passing; alerts: none/i);
assert.match(result.stdout, /pre-drill: healthy \(0 page alerts\)/i);
assert.equal(server.outageSwitchCount, 1, 'drill must flip the simulated switch exactly once');
assert.match(result.stdout, /post-outage: exactly 1 alert/i);
assert.match(result.stdout, /PAGE worker\.unavailable on daily-2026-08-22\.1/);
assert.match(result.stdout, /manual fallback: AVAILABLE — degraded elsewhere; local journal remains usable/);
assert.match(result.stdout, /DRILL PASS/);
});
test('drill refuses unknown, degraded, malformed, and backlog-warning baselines with exit 2 before any POST', async t => {
const unhealthyBaselines = [
{
name: 'unknown telemetry',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'unknown', latencyMs: 12 },
{ id: 'api.analyze', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 1 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 800 },
],
},
{
name: 'degraded check',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 },
{ id: 'api.analyze', boundary: 'api', status: 'degraded', failureClass: 'vision.timeout' },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 1 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 800 },
],
},
{
name: 'malformed check',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 },
{ id: 'BROKEN ID', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 1 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 800 },
],
},
{
name: 'backlog warning',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 },
{ id: 'api.analyze', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 12 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 800 },
],
},
{
name: 'missing boundary',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 1 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 800 },
],
},
];
for (const baseline of unhealthyBaselines) {
const server = await startDrillServer({ preDrillChecks: baseline.checks });
t.after(() => server.close());
const result = await runCli(['--drill-origin', server.origin]);
assert.equal(result.code, 2, `${baseline.name}: expected refusal exit 2, got ${result.code}`);
assert.match(result.stderr, /not a genuine drill baseline/i, `${baseline.name}: operator must be told why`);
assert.doesNotMatch(result.stdout, /DRILL PASS|WORKER OUTAGE DRILL/, `${baseline.name}: drill must never start`);
assert.equal(server.outageSwitchCount, 0, `${baseline.name}: switch must never be touched`);
}
});
test('drill refuses to flip an already-outaged fixture and never touches its switch', async t => {
const server = await startDrillServer({ startDown: true });
t.after(() => server.close());
const result = await runCli(['--drill-origin', server.origin]);
assert.equal(result.code, 2, `expected refusal exit 2, got ${result.code}`);
assert.match(result.stderr, /not healthy/i);
assert.doesNotMatch(result.stdout, /DRILL PASS/);
assert.equal(server.outageSwitchCount, 0, 'already-down fixture must not be flipped again');
});
test('drill fails when the flip produces no genuine healthy-to-outage transition', async t => {
const server = await startDrillServer({ flipHasNoEffect: true });
t.after(() => server.close());
const result = await runCli(['--drill-origin', server.origin]);
assert.equal(result.code, 1);
assert.match(result.stdout, /no outage observed after flip/i);
assert.match(result.stdout, /DRILL FAIL/);
assert.equal(server.outageSwitchCount, 1);
});
test('drill refuses a credentialed origin even when its loopback target is alive', async t => {
const server = await startDrillServer();
t.after(() => server.close());
const credentialed = `http://user:pass@127.0.0.1:${new URL(server.origin).port}`;
const result = await runCli(['--drill-origin', credentialed]);
assert.equal(result.code, 2, `expected refusal exit 2, got ${result.code}`);
assert.match(result.stderr, /drill origin rejected/i);
assert.doesNotMatch(result.stdout, /DRILL PASS|WORKER OUTAGE DRILL/, 'hostile origin must never reach the drill');
assert.equal(server.outageSwitchCount, 0, 'credentialed origin must never touch the fixture');
});
test('drill-origin accepts only validated loopback HTTP and rejects every hostile form without contacting it', async () => {
const hostileOrigins = [
'https://127.0.0.1:43600',
'http://localhost:43600',
'ftp://127.0.0.1:43600',
'file:///etc/passwd',
'http://0x7f.0.0.1:43600',
'http://2130706433:43600',
'http://[::1]:43600',
'http://[::ffff:127.0.0.1]:43600',
'http://[0:0:0:0:0:0:0:1]:43600',
'http://127.0.0.2:43600',
'http://10.0.0.5:43600',
'http://example.com:43600',
'http://127%2E0%2E0%2E1:43600',
'http://127.0.0.1:43600/drill',
'http://127.0.0.1:43600/?next=1',
'//127.0.0.1:43600',
'127.0.0.1:43600',
'not a url at all',
'null',
];
for (const origin of hostileOrigins) {
const result = await runCli(['--drill-origin', origin]);
assert.equal(result.code, 2, `expected refusal exit 2 for origin ${JSON.stringify(origin)}`);
assert.match(result.stderr, /drill origin rejected/i, `origin must be rejected as an origin, not merely unreachable: ${JSON.stringify(origin)}`);
assert.doesNotMatch(result.stdout, /DRILL PASS|WORKER OUTAGE DRILL/, `hostile origin must never reach the drill: ${JSON.stringify(origin)}`);
}
});
+291
View File
@@ -0,0 +1,291 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sanitizeEvidence, buildDashboard, evaluateAlerts, DEFAULT_ALERT_RULES } from '../src/release-observability.js';
const healthyEvidence = () => sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 },
{ id: 'api.analyze', boundary: 'api', status: 'pass', counters: { ok: 40, fail: 0 } },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { depth: 0, ok: 30, fail: 0 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 900, counters: { ok: 25, fail: 0, abstain: 3 } },
],
}).evidence;
test('dashboard separates app, api, queue, and model boundaries', () => {
const dashboard = buildDashboard(healthyEvidence());
assert.equal(dashboard.ok, true);
assert.deepEqual(Object.keys(dashboard.boundaries).sort(), ['api', 'app', 'model', 'queue']);
assert.equal(dashboard.boundaries.app.pass, 1);
assert.equal(dashboard.boundaries.model.pass, 1);
assert.equal(dashboard.boundaries.model.latencyMs, 900);
assert.equal(dashboard.totals.checks, 4);
assert.equal(dashboard.totals.fail, 0);
assert.deepEqual(dashboard.failureClasses, {});
assert.equal(dashboard.identity.releaseTag, 'daily-2026-08-22.1');
assert.equal(dashboard.identity.commit, 'ca31e6d38bec649407f63880504554c59f2878ae');
});
test('dashboard summarizes privacy-safe failure classes without any payload text', () => {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'degraded', failureClass: 'vision.timeout' },
{ id: 'queue.depth', boundary: 'queue', status: 'pass' },
{ id: 'model.inference', boundary: 'model', status: 'fail', failureClass: 'model.error' },
{ id: 'model.inference.warmup', boundary: 'model', status: 'fail', failureClass: 'model.error' },
],
}).evidence;
const dashboard = buildDashboard(evidence);
assert.deepEqual(dashboard.failureClasses, { 'vision.timeout': 1, 'model.error': 2 });
assert.equal(dashboard.totals.fail, 2);
assert.equal(dashboard.totals.degraded, 1);
assert.equal(JSON.stringify(dashboard).includes('note'), false);
});
test('simulated worker outage fires exactly one actionable alert with owner, threshold, and runbook', () => {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 11 },
{ id: 'api.analyze', boundary: 'api', status: 'fail', failureClass: 'worker.unavailable' },
{ id: 'queue.depth', boundary: 'queue', status: 'fail', failureClass: 'worker.unavailable', counters: { depth: 17 } },
{ id: 'model.inference', boundary: 'model', status: 'fail', failureClass: 'worker.unavailable', counters: { timeout: 4 } },
],
}).evidence;
const dashboard = buildDashboard(evidence);
const alerts = evaluateAlerts(dashboard, DEFAULT_ALERT_RULES);
assert.equal(alerts.length, 1, `expected exactly one alert, got ${JSON.stringify(alerts)}`);
const alert = alerts[0];
assert.equal(alert.id, 'worker.outage');
assert.equal(alert.severity, 'page');
assert.equal(alert.owner, 'release-operator');
assert.match(alert.runbook, /docs\/RELEASE-OBSERVABILITY\.md#simulate-a-worker-outage/);
assert.match(alert.message, /worker\.unavailable/);
assert.match(alert.message, /daily-2026-08-22\.1/);
assert.equal(alert.count, 3);
assert.equal(alert.threshold, 1);
});
test('healthy evidence raises no alerts and every default rule carries owner, threshold, and runbook', () => {
assert.deepEqual(evaluateAlerts(buildDashboard(healthyEvidence()), DEFAULT_ALERT_RULES), []);
for (const rule of DEFAULT_ALERT_RULES) {
assert.ok(rule.owner && rule.owner.length > 0);
assert.ok(Number.isInteger(rule.threshold) && rule.threshold >= 1);
assert.match(rule.runbook, /^docs\/RELEASE-OBSERVABILITY\.md#/);
assert.ok(['page', 'warn'].includes(rule.severity));
}
});
test('manual fallback stays available whenever any non-app check is unhealthy', () => {
const healthy = buildDashboard(healthyEvidence());
assert.deepEqual(healthy.manualFallback, { available: true, reason: 'none-required' });
const outage = buildDashboard(sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'fail', failureClass: 'worker.unavailable' },
{ id: 'model.inference', boundary: 'model', status: 'pass' },
],
}).evidence);
assert.deepEqual(outage.manualFallback, {
available: true,
reason: 'local-journal',
});
});
test('manual fallback is unavailable only when the app boundary itself is down', () => {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'fail' },
{ id: 'api.analyze', boundary: 'api', status: 'fail', failureClass: 'worker.unavailable' },
{ id: 'queue.depth', boundary: 'queue', status: 'pass' },
{ id: 'model.inference', boundary: 'model', status: 'pass' },
],
}).evidence;
const dashboard = buildDashboard(evidence);
assert.deepEqual(dashboard.manualFallback, {
available: false,
reason: 'app-down',
});
});
test('manualFallback state is a deterministic function of boundary health alone', () => {
const cases = [
{ statuses: ['pass', 'pass', 'pass', 'pass'], expected: { available: true, reason: 'none-required' } },
{ statuses: ['degraded', 'pass', 'pass', 'pass'], expected: { available: true, reason: 'local-journal' } },
{ statuses: ['pass', 'unknown', 'pass', 'pass'], expected: { available: null, reason: 'app-state-unknown' } },
{ statuses: ['fail', 'fail', 'fail', 'fail'], expected: { available: false, reason: 'app-down' } },
];
for (const { statuses, expected } of cases) {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: statuses[0] },
{ id: 'api.analyze', boundary: 'api', status: statuses[1] },
{ id: 'queue.depth', boundary: 'queue', status: statuses[2] },
{ id: 'model.inference', boundary: 'model', status: statuses[3] },
],
}).evidence;
assert.deepEqual(buildDashboard(evidence).manualFallback, expected, `statuses ${statuses}`);
}
});
const backlogEvidence = depth => sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'degraded', counters: { depth } },
{ id: 'model.inference', boundary: 'model', status: 'pass' },
],
}).evidence;
test('queue.depth counter survives sanitization so queue.backlog can fire', () => {
const dashboard = buildDashboard(backlogEvidence(10));
assert.equal(dashboard.boundaries.queue.counters.depth, 10);
});
test('queue.backlog stays silent at depth 9 and fires at exactly depth 10', () => {
assert.deepEqual(evaluateAlerts(buildDashboard(backlogEvidence(9)), DEFAULT_ALERT_RULES), []);
const atThreshold = evaluateAlerts(buildDashboard(backlogEvidence(10)), DEFAULT_ALERT_RULES);
assert.equal(atThreshold.length, 1);
assert.equal(atThreshold[0].id, 'queue.backlog');
assert.equal(atThreshold[0].severity, 'warn');
assert.equal(atThreshold[0].count, 10);
assert.equal(atThreshold[0].threshold, 10);
});
test('queue.backlog reports observed count above threshold and sums across queue checks', () => {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'degraded', counters: { depth: 5 } },
{ id: 'queue.depth.sidecar', boundary: 'queue', status: 'pass', counters: { depth: 6 } },
{ id: 'model.inference', boundary: 'model', status: 'pass' },
],
}).evidence;
const alerts = evaluateAlerts(buildDashboard(evidence), DEFAULT_ALERT_RULES);
const backlog = alerts.filter(alert => alert.id === 'queue.backlog');
assert.equal(backlog.length, 1);
assert.equal(backlog[0].count, 11);
});
test('queue.backlog is suppressed while worker.outage pages because outage depth is residual', () => {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'fail', failureClass: 'worker.unavailable' },
{ id: 'queue.depth', boundary: 'queue', status: 'fail', failureClass: 'worker.unavailable', counters: { depth: 17 } },
{ id: 'model.inference', boundary: 'model', status: 'fail', failureClass: 'worker.unavailable' },
],
}).evidence;
const alerts = evaluateAlerts(buildDashboard(evidence), DEFAULT_ALERT_RULES);
assert.equal(alerts.length, 1, `expected only the outage page, got ${JSON.stringify(alerts)}`);
assert.equal(alerts[0].id, 'worker.outage');
});
test('queue.bound depth counter above the bounded ceiling cannot smuggle into the dashboard', () => {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'degraded', counters: { depth: 10_000_000 } },
{ id: 'model.inference', boundary: 'model', status: 'pass' },
],
}).evidence;
assert.equal(evidence, null, 'unbounded depth must fail closed at sanitization');
});
test('sanitizer fails closed when any app, api, queue, or model boundary is entirely missing', () => {
const fullChecks = [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'pass' },
{ id: 'queue.depth', boundary: 'queue', status: 'pass' },
{ id: 'model.inference', boundary: 'model', status: 'pass' },
];
for (const missing of ['app', 'api', 'queue', 'model']) {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: fullChecks.filter(check => check.boundary !== missing),
});
assert.equal(evidence.ok, false, `missing ${missing} boundary must fail closed`);
}
});
test('unknown statuses surface as a warn telemetry.gap alert instead of failing open', () => {
const evidence = sanitizeEvidence({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass' },
{ id: 'api.analyze', boundary: 'api', status: 'unknown' },
{ id: 'queue.depth', boundary: 'queue', status: 'unknown' },
{ id: 'model.inference', boundary: 'model', status: 'pass' },
],
}).evidence;
assert.ok(evidence, 'unknown is an allowed status and must not fail sanitization');
const dashboard = buildDashboard(evidence);
assert.equal(dashboard.boundaries.api.unknown, 1);
const alerts = evaluateAlerts(dashboard, DEFAULT_ALERT_RULES);
assert.equal(alerts.length, 1, `expected exactly the telemetry.gap alert, got ${JSON.stringify(alerts)}`);
const gap = alerts[0];
assert.equal(gap.id, 'telemetry.gap');
assert.equal(gap.severity, 'warn');
assert.equal(gap.owner, 'release-operator');
assert.equal(gap.threshold, 1);
assert.equal(gap.count, 2);
assert.match(gap.runbook, /docs\/RELEASE-OBSERVABILITY\.md#/);
assert.match(gap.message, /telemetry\.gap/);
});
test('healthy evidence raises no telemetry.gap alert', () => {
const alerts = evaluateAlerts(buildDashboard(healthyEvidence()), DEFAULT_ALERT_RULES);
assert.equal(alerts.some(alert => alert.id === 'telemetry.gap'), false);
});
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { DEFAULT_ALERT_RULES } from '../src/release-observability.js';
const runbookPath = new URL('../docs/RELEASE-OBSERVABILITY.md', import.meta.url);
const packagePath = new URL('../package.json', import.meta.url);
test('every default alert rule is inventoried in the runbook with owner, threshold, and severity', async () => {
const runbook = await readFile(runbookPath, 'utf8');
const headingSlugs = [...runbook.matchAll(/^#{2,4} (.+)$/gm)]
.map(match => match[1].toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''));
for (const rule of DEFAULT_ALERT_RULES) {
const pattern = new RegExp(
`\\| \`${rule.id}\`\\s*\\| ${rule.severity}\\s*\\| ${rule.owner}\\s*\\|`,
);
assert.match(runbook, pattern, `runbook table lacks a conforming row for ${rule.id}`);
const anchor = rule.runbook.split('#')[1];
assert.ok(anchor && headingSlugs.includes(anchor), `runbook link for ${rule.id} does not resolve to a heading (${anchor})`);
}
});
test('runbook table states each rule\'s actual numeric threshold, matching DEFAULT_ALERT_RULES', async () => {
const runbook = await readFile(runbookPath, 'utf8');
const rows = Object.fromEntries(
runbook.split('\n')
.filter(line => line.startsWith('| `'))
.map(line => [line.slice(3, line.indexOf('`', 3)), line]),
);
for (const rule of DEFAULT_ALERT_RULES) {
// The threshold must appear as a real number in the rule's own table row,
// not merely somewhere in prose, so the documented contract cannot drift
// from what evaluateAlerts() enforces.
const row = rows[rule.id];
assert.ok(row, `runbook table has no row for ${rule.id}`);
assert.match(
row,
new RegExp(`\\| ${rule.threshold}\\b`),
`runbook row for ${rule.id} must state its real numeric threshold ${rule.threshold}`,
);
}
const backlogRule = DEFAULT_ALERT_RULES.find(rule => rule.id === 'queue.backlog');
assert.equal(backlogRule.threshold, 10, 'queue.backlog must fire at exactly depth 10');
assert.match(runbook, /depth >= 10/, 'runbook must state the queue.backlog boundary as depth >= 10');
});
test('runbook documents the outage drill anchor, manual fallback, and privacy boundary', async () => {
const runbook = await readFile(runbookPath, 'utf8');
assert.match(runbook, /^## Simulate a worker outage$/m);
assert.match(runbook, /^## Alert inventory$/m);
assert.match(runbook, /manual fallback/i);
for (const forbidden of ['photos or medical imagery', 'stool records', 'session identifiers', 'environment dumps']) {
assert.match(runbook, new RegExp(forbidden.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'), 'i'));
}
const externalUrls = [...runbook.matchAll(/https?:\/\/(?!127\.0\.0\.1)[^\s)`\]]+/g)].map(match => match[0]);
assert.deepEqual(externalUrls, [], 'runbook must not reference non-loopback hosts');
});
test('package scripts wire observability tests into the suite and syntax gate', async () => {
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
assert.match(packageJson.scripts.test, /tests\/release-observability\.test\.js/);
assert.match(packageJson.scripts.test, /tests\/release-dashboard\.test\.js/);
assert.match(packageJson.scripts.test, /tests\/release-dashboard-cli\.test\.js/);
assert.match(packageJson.scripts['check:syntax'], /node --check scripts\/release_dashboard\.mjs/);
});
+229
View File
@@ -0,0 +1,229 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sanitizeEvidence } from '../src/release-observability.js';
const validEvidence = () => ({
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', latencyMs: 12 },
{ id: 'api.analyze', boundary: 'api', status: 'pass', counters: { ok: 40, fail: 1 } },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', counters: { ok: 30, fail: 0 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', latencyMs: 900, counters: { ok: 25, fail: 0, abstain: 3 } },
],
});
test('sanitizer keeps bounded sanitized release evidence intact', () => {
const result = sanitizeEvidence(validEvidence());
assert.equal(result.ok, true);
assert.deepEqual(result.evidence, {
schemaVersion: 1,
releaseTag: 'daily-2026-08-22.1',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
generatedAtUtc: '2026-08-22T12:00:00Z',
checks: [
{ id: 'app.healthz', boundary: 'app', status: 'pass', failureClass: null, latencyMs: 12, counters: {} },
{ id: 'api.analyze', boundary: 'api', status: 'pass', failureClass: null, counters: { ok: 40, fail: 1 } },
{ id: 'queue.depth', boundary: 'queue', status: 'pass', failureClass: null, counters: { ok: 30, fail: 0 } },
{ id: 'model.inference', boundary: 'model', status: 'pass', failureClass: null, latencyMs: 900, counters: { ok: 25, fail: 0, abstain: 3 } },
],
});
});
test('sanitizer fail-closes evidence carrying session identifiers, photo payloads, credentials, and free-text notes', () => {
// Assembled at runtime so no contiguous private-key marker ever lands in Git history.
const privateKeyFixture = `-----BEGIN ${'OPENSSH'} PRIVATE KEY${'-----'}`;
// Forbidden-shaped fields are a rejection of the whole evidence object, not
// a silent drop: censoring a hostile check would dress the gap up as health.
const hostile = {
...validEvidence(),
sessionToken: 'sess_live_abc123',
adminCookie: 'timmy_agent=secret-cookie-value',
environmentDump: { NODE_ENV: 'production', SECRET_TOKEN: 'must-not-leak' },
operatorEmail: 'someone@example.com',
credentialFile: privateKeyFixture,
photos: [{ dataBase64: 'aGVsbG8gd29ybGQgaGVsbG8gd29ybGQ=' }],
};
assert.equal(sanitizeEvidence(hostile).ok, false, 'forbidden top-level fields must reject the whole evidence object');
const leakyCheck = {
id: 'leak',
boundary: 'app',
status: 'pass',
noteText: 'patient said blood at 3am, see photo hash e3b0c442',
imageHash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
base64Payload: 'aGVsbG8=',
authHeader: `Bearer ${'sk-'}live-abcdef1234567890`,
};
const withLeakyCheck = validEvidence();
withLeakyCheck.checks = [...validEvidence().checks, leakyCheck];
const result = sanitizeEvidence(withLeakyCheck);
assert.equal(result.ok, false, 'a check carrying forbidden or free-text fields must reject the whole evidence object');
});
test('sanitizer fail-closes evidence carrying hostile failure-class payloads', () => {
const hostileFailureClasses = [
'worker.unavailable\nsecond line',
'worker.unavailable\r\ncarriage',
'\x1b[31mANSI-red',
'worker.unavailable\x1b[0m',
'bell\x07class',
'tab\tseparated',
'DROP TABLE users',
'patient reported blood at 3am',
'sess_live_abc123def456',
'-----BEGIN OPENSSH PRIVATE KEY-----',
'a@b.example.com',
'.leading.dot',
'trailing.dot.',
'double..dot',
'-leading-dash.d',
'd.trailing-dash-',
'-'.repeat(65),
42,
{},
[],
];
for (const failureClass of hostileFailureClasses) {
const evidence = validEvidence();
evidence.checks = [{ id: 'app.healthz', boundary: 'app', status: 'fail', failureClass }];
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `expected rejection for failureClass ${JSON.stringify(failureClass)}`);
}
});
test('sanitizer accepts only the exact privacy-safe failure-class vocabulary in slug grammar', () => {
const baseChecks = validEvidence().checks;
for (const failureClass of ['worker.unavailable', 'vision.timeout', 'model.error']) {
const evidence = validEvidence();
evidence.checks = [
...baseChecks.filter(check => check.boundary !== 'api'),
{ id: 'api.analyze', boundary: 'api', status: 'degraded', failureClass },
];
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, true, `vocabulary class ${failureClass} must survive sanitization`);
assert.equal(result.evidence.checks.some(check => check.failureClass === failureClass), true);
}
for (const failureClass of ['made.up.class', 'worker.somethingelse', 'VISION.TIMEOUT', 'vision..timeout']) {
const evidence = validEvidence();
evidence.checks = [
...baseChecks.filter(check => check.boundary !== 'api'),
{ id: 'api.analyze', boundary: 'api', status: 'degraded', failureClass },
];
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `non-vocabulary class ${failureClass} must be rejected`);
}
});
test('schemaVersion must be the JSON number 1 and nothing else', () => {
for (const hostileVersion of ['1', true, false, null, 1.5, 0, 2, [1], { value: 1 }]) {
const evidence = { ...validEvidence(), schemaVersion: hostileVersion };
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `schemaVersion ${JSON.stringify(hostileVersion)} must be rejected`);
}
});
test('counters and latency must be actual JSON numbers, never strings, booleans, nulls, or fractions', () => {
for (const counters of [{ ok: '40' }, { ok: true }, { fail: null }, { depth: 10.5 }, { depth: -1 }, { retry: Infinity }]) {
const evidence = validEvidence();
evidence.checks[0] = { ...evidence.checks[0], counters };
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `counters ${JSON.stringify(counters)} must be rejected`);
}
for (const latencyMs of ['12', '', true, false, null, 12.5, -1, Infinity]) {
const evidence = validEvidence();
evidence.checks[0] = { ...evidence.checks[0], latencyMs };
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `latencyMs ${JSON.stringify(latencyMs)} must be rejected`);
}
});
test('generatedAtUtc must be canonical real UTC that survives a Date round-trip', () => {
const invalidTimestamps = [
'2026-02-30T12:00:00Z',
'2026-08-22T25:00:00Z',
'2026-08-22T12:61:00Z',
'2026-13-01T00:00:00Z',
'2026-08-22T12:00:00+00:00',
'2026-08-22t12:00:00z',
'2026-08-22 12:00:00Z',
'not-a-timestamp',
1234567890,
true,
null,
];
for (const generatedAtUtc of invalidTimestamps) {
const evidence = { ...validEvidence(), generatedAtUtc };
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `generatedAtUtc ${JSON.stringify(generatedAtUtc)} must be rejected`);
}
});
test('releaseTag and commit must have their exact safe types', () => {
for (const releaseTag of [42, true, null, {}, [], '']) {
const evidence = { ...validEvidence(), releaseTag };
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `releaseTag ${JSON.stringify(releaseTag)} must be rejected`);
}
for (const commit of [0xca31e6d38bec, true, null, {}, []]) {
const evidence = { ...validEvidence(), commit };
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `commit ${JSON.stringify(commit)} must be rejected`);
}
});
test('one malformed check rejects the entire evidence object instead of vanishing beside healthy checks', () => {
const malformedChecks = [
{ boundary: 'app', status: 'pass' },
{ id: 'BAD_ID', boundary: 'app', status: 'pass' },
{ id: 42, boundary: 'app', status: 'pass' },
{ id: 'app.healthz', status: 'pass' },
{ id: 'app.healthz', boundary: 'bus', status: 'pass' },
{ id: 'app.healthz', boundary: 42, status: 'pass' },
{ id: 'app.healthz', boundary: 'app' },
{ id: 'app.healthz', boundary: 'app', status: 'healthy' },
{ id: 'app.healthz', boundary: 'app', status: 42 },
{ id: 'app.healthz', boundary: 'app', status: 'pass', counters: 'many' },
{ id: 'app.healthz', boundary: 'app', status: 'pass', counters: [] },
{ id: 'app.healthz', boundary: 'app', status: 'pass', failureClass: 42 },
];
for (const malformed of malformedChecks) {
const evidence = validEvidence();
evidence.checks = [...validEvidence().checks, malformed];
const result = sanitizeEvidence(evidence);
assert.equal(result.ok, false, `malformed check ${JSON.stringify(malformed)} must reject the whole evidence object`);
}
});
test('a passing or unknown check can never carry a failureClass, so all-pass evidence can never page worker.outage', () => {
const baseChecks = validEvidence().checks;
for (const status of ['pass', 'unknown']) {
for (const failureClass of ['worker.unavailable', 'model.error', 'vision.timeout']) {
const evidence = validEvidence();
evidence.checks = baseChecks.map(check =>
check.boundary === 'app' ? { ...check, status, failureClass } : check,
);
const result = sanitizeEvidence(evidence);
assert.equal(
result.ok,
false,
`status ${status} carrying failureClass ${failureClass} must reject the whole evidence object`,
);
}
}
});
test('sanitization is idempotent: re-sanitizing sanitized evidence changes nothing and omits nullable latency', () => {
const first = sanitizeEvidence(validEvidence());
assert.equal(first.ok, true);
assert.equal(Object.hasOwn(first.evidence.checks[0], 'latencyMs'), true, 'present latency must stay present');
assert.equal(Object.hasOwn(first.evidence.checks[1], 'latencyMs'), false, 'omitted latency must stay omitted');
const second = sanitizeEvidence(first.evidence);
assert.equal(second.ok, true, 'sanitized evidence must survive a second sanitization pass');
assert.deepEqual(second.evidence, first.evidence, 'second pass must not rewrite omitted latency to a different shape');
assert.equal(JSON.stringify(second.evidence).includes('"latencyMs":0'), false, 'no zero-latency fabrication');
});
+2 -2
View File
@@ -43,7 +43,7 @@ async function dispatchFetch(handler, request) {
return response;
}
test('root activation deletes only its obsolete Timmy caches including the legacy v4 and previous v5 shells', async () => {
test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => {
const { listeners, deleted } = loadWorker({
cacheKeys: [
'timmy-shell-v4',
@@ -56,7 +56,7 @@ test('root activation deletes only its obsolete Timmy caches including the legac
await dispatchExtendable(listeners.get('activate'));
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4', 'timmy-shell:/:v5']);
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']);
});
test('offline shell lookup uses only the current named cache', async () => {
+1 -18
View File
@@ -148,23 +148,6 @@ test('malformed and escaping base paths are rejected at startup', async () => {
'/timmy\nstaging',
];
// The rejection must happen at startup, so the wait budget is anchored to
// the measured cold-start time of the server itself rather than a magic
// constant that silently breaks on loaded or cold machines.
const timedSpawn = () => new Promise(resolve => {
const startedAt = Date.now();
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: '/' },
stdio: ['ignore', 'ignore', 'ignore'],
});
child.once('exit', () => resolve(Date.now() - startedAt));
setTimeout(() => { child.kill('SIGKILL'); }, 5000);
});
await timedSpawn(); // warm caches once; timing after this reflects steady state
const baseline = await timedSpawn();
const budget = Math.max(2000, baseline * 4 + 500);
for (const value of invalid) {
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
@@ -175,7 +158,7 @@ test('malformed and escaping base paths are rejected at startup', async () => {
child.stderr.on('data', chunk => { stderr += chunk; });
const exitCode = await Promise.race([
new Promise(resolve => child.once('exit', resolve)),
new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, budget)),
new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, 800)),
]);
assert.notEqual(exitCode, 'timeout', `${JSON.stringify(value)} was accepted`);
assert.notEqual(exitCode, 0, `${JSON.stringify(value)} exited successfully`);
+1 -2
View File
@@ -9,8 +9,7 @@ const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173';
await page.goto(appUrl, { waitUntil: 'networkidle' });
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });